From 1fa2924abd8be43224aa3eca6c54643eadca08b6 Mon Sep 17 00:00:00 2001 From: Epagris Date: Tue, 30 Sep 2025 22:44:06 +0200 Subject: [PATCH] - ... --- class/Game.php | 515 ++++++++++++++++++++++++ class/GameMgr.php | 367 +---------------- class/Group.php | 226 +++++++++++ class/GroupMgr.php | 223 +--------- class/LogicFunction.php | 153 +++++++ class/ReportBuilder.php | 18 +- class/Task.php | 144 +++++++ class/TaskFactory.php | 36 ++ class/Tasks/NumberConversionTask.php | 159 ++++++++ class/Tasks/OpenEndedTask.php | 63 +++ class/Tasks/PicturedTask.php | 31 ++ class/Tasks/SingleChoiceTask.php | 85 ++++ class/Tasks/TruthTableTask.php | 18 + class/Test.php | 251 ++++++++++++ class/TestMgr.php | 581 ++------------------------- class/TestSummary.php | 65 +++ class/User.php | 134 ++++++ class/UserMgr.php | 131 +----- class/VerilogUtils.php | 9 + cli_actions.php | 23 ++ composer.json | 6 +- game_manager_frame.php | 8 +- interface.php | 114 ++++-- js/default_frame.js | 6 +- js/gamemgr.js | 22 +- js/result_analyzer.js | 30 +- js/tasks.js | 465 +++++++++++++++++++++ js/terminal.js | 17 + js/testground.js | 199 +++++---- js/usermgr.js | 4 +- main.php | 34 +- report.html | 1 + style/quizmaster_area.css | 6 + style/report.css | 74 ++++ style/spreadquiz.css | 82 ---- style/spreadquiz_mobile.css | 14 +- testground.php | 8 + 37 files changed, 2815 insertions(+), 1507 deletions(-) create mode 100644 class/Game.php create mode 100644 class/Group.php create mode 100644 class/LogicFunction.php create mode 100644 class/Task.php create mode 100644 class/TaskFactory.php create mode 100644 class/Tasks/NumberConversionTask.php create mode 100644 class/Tasks/OpenEndedTask.php create mode 100644 class/Tasks/PicturedTask.php create mode 100644 class/Tasks/SingleChoiceTask.php create mode 100644 class/Tasks/TruthTableTask.php create mode 100644 class/Test.php create mode 100644 class/TestSummary.php create mode 100644 class/User.php create mode 100644 class/VerilogUtils.php create mode 100644 js/tasks.js create mode 100644 js/terminal.js create mode 100644 style/report.css diff --git a/class/Game.php b/class/Game.php new file mode 100644 index 0000000..2ae5475 --- /dev/null +++ b/class/Game.php @@ -0,0 +1,515 @@ + false, // player may traverse back and forth between tasks + "time_limit" => 0, // no time limit; otherwise, this field indicates time limit in seconds + "repeatable" => false // this test can be taken multiple times + ]; + + public const CURRENT_GAME_VERSION = 2; // MUST BE INCREMENTED!! + + // -------- + private int $id; // Game's ID + private string $name; // Game's name + private string $owner; // Game's owner + private array $contributors; // Contributors to the game + private string $description; // Game's description + private bool $gameFileIsPresent; // Indicates if game CSV is in place + private array $properties; // Collection of several game properties + private bool $public; // Is this game publicly available? + private string $publicId; // Public-accessible ID + private int $VERSION; // Game representation version (used during updates) + private GameMgr $gameMgr; // Game manager managing this instance + private bool $tasksLoaded; // Indicates if tasks have been fetched + private array $tasks; // Tasks + + // ------- + + static public function genPublicId(): string + { + return uniqid("p"); + } + + // ------- + + static private function patchUpGameData(array &$a): void + { + $version = $a["version"] ?? 0; + if ($version < 2) { // update to game version 2 + if (!key_exists("public_id", $a)) { + $a["public"] = false; + $a["public_id"] = self::genPublicId(); + } + + $a["version"] = 2; + } + } + + // Store modifications. + public function storeMods(): void + { + $this->gameMgr->updateGame($this); + } + + // Commit modifications. + function commitMods(): void + { + //$this->patchUpGameDate(); + parent::commitMods(); + } + + // Load game tasks. + public function loadTasks(): void + { + if ($this->isGameFileIsPresent() && !$this->tasksLoaded) { // load if file is present + $this->tasks = TaskFactory::constructFromCollection(json_decode(file_get_contents($this->getGameFile()), true)); + foreach ($this->tasks as &$ch) { + $ch->setTemplate(true); + } + } + } + + // Save tasks. + public function saveTasks(): void + { + file_put_contents($this->getGameFile(), json_encode($this->tasks)); // store tasks in JSON-format + } + + // ------- + + function __construct(GameMgr &$gameMgr, string $name, string $description = "", int $id = -1, string $owner = "", + array $contributors = [], bool $gameFileIsPresent = false, array $properties = [], + bool $public = false, string $publicId = "", int $version = 2) + { + parent::__construct(); + + $this->tasksLoaded = false; + + $this->gameMgr = $gameMgr; + $this->id = $id; + $this->name = $name; + $this->description = $description; + $this->owner = $owner; + $this->contributors = $contributors; + $this->gameFileIsPresent = $gameFileIsPresent; + $this->properties = $properties; + $this->public = $public; + $this->publicId = $publicId; + $this->VERSION = $version; + $this->tasks = []; + } + + // Create game from array representation. + static function fromArray(GameMgr &$gameMgr, array $a): Game + { + $id = $a["_id"] ?? -1; + self::patchUpGameData($a); + return new Game($gameMgr, $a["name"], $a["description"], $id, $a["owner"], $a["contributors"], + $a["game_file_present"], $a["properties"], $a["public"], $a["public_id"], $a["version"]); + } + + const OMIT_ADVANCED_FIELDS = ["contributors", "game_file_is_present", "properties", "public", "public_id", "version"]; + + // Convert game to array representation. + function toArray(array $omit = []): array + { + $a = [ + "_id" => $this->id, + "name" => $this->name, + "description" => $this->description, + "owner" => $this->owner, + "contributors" => $this->contributors, + "game_file_present" => $this->gameFileIsPresent, + "properties" => $this->properties, + "public" => $this->public, + "public_id" => $this->publicId, + "version" => $this->VERSION, + ]; + + foreach ($omit as $field) { + unset($a[$field]); + } + + return $a; + } + + // Export tasks to a CSV file. TODO: ez csak a feleletválasztóshoz lesz jó + function exportTasksToCSV(&$f): void + { + // load tasks + $this->loadTasks(); + + // populate CSV file + foreach ($this->tasks as $ch) { + if ($ch->getType() === "singlechoice") { + $csvline = [ + $ch["question"], + $ch["image_url"], + ]; + $csvline = array_merge($csvline, $ch["answers"]); + fputcsv($f, $csvline); + } + } + } + + // Get game directory NAME with path. Does not check if the game directory exists or not. + function getGameDir(): string + { + return GAMEMEDIA_DIR . DIRECTORY_SEPARATOR . $this->getId(); + } + + // Get game file NAME with path. Does not check whether the game file is in place or not. + function getGameFile(): string + { + return GAMEMEDIA_DIR . DIRECTORY_SEPARATOR . $this->getId() . DIRECTORY_SEPARATOR . GAME_FILE; + } + + // Is the given user the owner of the game? + function isUserOwner(string $nickname): bool + { + return $this->owner === $nickname; + } + + // Is the given user a contributor of the game? + function isUserContributor(string $nickname): bool + { + return in_array($nickname, $this->contributors); + } + + // Is user contributor or owner? + function isUserContributorOrOwner(string $nickname): bool + { + return $this->isUserContributor($nickname) || $this->isUserOwner($nickname); + } + + const CSV_ENCODINGS = ["UTF-8", "Windows-1252"]; + + // Import tasks from a CSV table. TODO: ez csak a feleletválasztós betöltésére lesz jó + function importTasksFromCSV(string $csv_path): array + { + // convert text encoding into UTF-8 + $data = file_get_contents($csv_path); + $encoding = "UNKNOWN"; + foreach (self::CSV_ENCODINGS as $enc) { // detect encoding + if (mb_check_encoding($data, $enc)) { + $encoding = $enc; + break; + } + } + + if ($encoding !== "UNKNOWN") { // if encoding has been detected successfully + $data = mb_convert_encoding($data, "UTF-8", $encoding); + file_put_contents($csv_path, $data); + } + + // clear tasks + $this->tasks = []; + + // load filled CSV file + $f = fopen($csv_path, "r"); + if (!$f) { // failed to open file + return ["n" => 0, "encoding" => $encoding]; + } + while ($csvline = fgetcsv($f)) { + // skip empty lines + if (trim(implode("", $csvline)) === "") { + continue; + } + if (count($csvline) >= 3) { + // construct task record + $a = [ + "question" => trim($csvline[0]), + "image_url" => trim($csvline[1]), + "correct_answer" => 0, + "answers" => array_filter(array_slice($csvline, 2), function ($v) { + return trim($v) !== ""; + }) + ]; + + // if an image is attached to the task, then give a random name to the image + if ($a["image_url"] !== "") { + $a["image_url"] = $this->obfuscateAttachedImage($a["image_url"]); + } + + // store the task + $this->tasks[] = new SingleChoiceTask($a); + } + } + fclose($f); + + // save tasks + $this->saveTasks(); + + // update game with game file present + $this->gameFileIsPresent = true; + + // store modifications + $this->commitMods(); + + return ["n" => count($this->tasks), "encoding" => $encoding]; + } + + private static function getTableVersion(string &$cA1): string + { + if (str_starts_with($cA1, "#:V")) { + return trim(substr($cA1, 3)); + } else { + return "1"; + } + } + + private function obfuscateAttachedImage(string $old_img_name): string + { + $ext = pathinfo($old_img_name, PATHINFO_EXTENSION); + $ext = ($ext !== "") ? ("." . $ext) : $ext; + $new_img_name = uniqid("img_", true) . $ext; + + // rename the actual file + $old_img_path = $this->getGameDir() . DIRECTORY_SEPARATOR . $old_img_name; + $new_img_path = $this->getGameDir() . DIRECTORY_SEPARATOR . $new_img_name; + rename($old_img_path, $new_img_path); + + return $new_img_name; + } + + private function importTasksFromTableV1(array &$table): array + { + $n = count($table); + for ($i = 1; $i < $n; $i++) { + $row = &$table[$i]; // fetch row + $a = [ // create initializing array + "question" => trim($row[0]), + "image_url" => trim($row[1]), + "correct_answer" => 0, + "answers" => array_filter(array_slice($row, 2), function ($v) { + return trim($v ?? "") !== ""; + }) + ]; + + // obfuscate image filename + if ($a["image_url"] !== "") { + $a["image_url"] = $this->obfuscateAttachedImage($a["image_url"]); + } + + // create the task + $this->tasks[] = new SingleChoiceTask($a); + } + + return ["n" => $n, "encoding" => "automatikusan konvertált"]; + } + + private static function getTableColumnIndices(array &$header): array + { + $columns = []; + for ($i = 1; $i < count($header); $i++) { // skip the first column as it is metadata + $label = $header[$i]; + if (($label ?? "") !== "") { + $columns[$label] = $i; + } + } + return $columns; + } + + private static function getFirstUnlabeledColumn(array &$header): int + { + for ($i = 0; $i < count($header); $i++) { + if (trim($header[$i] ?? "") === "") { + return $i; + } + } + + return -1; + } + + private static function explodeFlags(string $fs): array { + $flags = explode(",", trim($fs)); + return array_filter($flags, fn($v) => trim($v) !== ""); + } + + private function importTasksFromTableV2(array &$table): array + { + $result = ["n" => 0, "encoding" => "automatikusan konvertált"]; // prepare result + $n = count($table); // get number of entries (including header) + if ($n === 0) { // cannot import an empty table + return $result; + } + $header = &$table[0]; // extract header + + $fuc = Game::getFirstUnlabeledColumn($header); // get first unlabeled column + if ($fuc === -1) { // if there's no data, then it is impossible to create the tasks + return $result; + } + + $columns = Game::getTableColumnIndices($header); // fetch column names + + // start iterating over tasks + for ($i = 1; $i < $n; $i++) { + $row = &$table[$i]; // fetch row + + // prepare a function that looks up the fields referenced by their labels + $select_fn = function (array $cols) use (&$row, &$columns) { + for ($i = 0; $i < count($cols); $i++) { + if (isset($columns[$cols[$i]])) { + return trim($row[$columns[$cols[$i]]]); + } + } + return ""; + }; + + // prepare a function that extracts all unlabeled fields + $extract_unlabeled_fn = fn() => array_filter(array_slice($row, $fuc), function ($v) { + return trim($v ?? "") !== ""; + }); + + // fetch generic fields + $a = [ + "flags" => Game::explodeFlags($row[0] ?? ""), + "type" => strtolower($select_fn(["Típus", "Type"])), + "generator" => $select_fn(["Generátor", "Generator"]), + "image_url" => $select_fn(["Kép", "Image"]), + "question" => $select_fn(["Kérdés", "Question"]), + ]; + + // convert into + switch ($a["type"]) { + case "singlechoice": + $a["answers"] = $extract_unlabeled_fn(); + $a["correct_answer"] = 0; + break; + case "openended": + $a["correct_answers"] = $extract_unlabeled_fn(); + break; + case "numberconversion": + $a["instruction"] = $row[$fuc]; + break; + } + + // generate the task + $this->tasks[] = TaskFactory::fromArray($a); + } + + $result["n"] = $n - 1; + return $result; + } + + public function importTasksFromTable(array &$table): array + { + // clear tasks + $this->tasks = []; + + // get table version + $vs = Game::getTableVersion($table[0][0]); + + // continue processing based on table version + $result = ["n" => 0, "encoding" => "ismeretlen"]; + switch ($vs) { + case "1": + $result = $this->importTasksFromTableV1($table); + break; + case "2": + $result = $this->importTasksFromTableV2($table); + break; + } + + // if the number of imported tasks is not zero, then it was a successful import + $this->gameFileIsPresent = false; // assume no game file present + if ($result["n"] > 0) { + $this->saveTasks(); // save tasks + $this->gameFileIsPresent = true; // update game with game file present + $this->commitMods(); // store modifications + } + + return $result; + } + + // --------- + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getOwner(): string + { + return $this->owner; + } + + public function setOwner(string $owner): void + { + $this->owner = $owner; + } + + public function getContributors(): array + { + return $this->contributors; + } + + public function setContributors(array $contributors): void + { + $this->contributors = $contributors; + } + + public function getDescription(): string + { + return $this->description; + } + + public function setDescription(string $description): void + { + $this->description = $description; + } + + public function getId(): int + { + return $this->id; + } + + public function isGameFileIsPresent(): bool + { + return $this->gameFileIsPresent; + } + + function setProperties(array $properties): void + { + $this->properties = $properties; + $this->commitMods(); + } + + public function& getProperties(): array + { + return $this->properties; + } + + public function isPublic(): bool + { + return $this->public; + } + + public function getPublicId(): string + { + return $this->publicId; + } + + public function setPublic(bool $public): void + { + $this->public = $public; + $this->commitMods(); + } + + public function getTasks(): array + { + $this->loadTasks(); + return $this->tasks; + } +} \ No newline at end of file diff --git a/class/GameMgr.php b/class/GameMgr.php index 539689e..5e4ce16 100644 --- a/class/GameMgr.php +++ b/class/GameMgr.php @@ -4,351 +4,7 @@ require_once "vendor/autoload.php"; require_once "AutoStoring.php"; -class Game extends AutoStoring -{ - public const DEFAULT_GAME_PROPERTIES = [ - "forward_only" => false, // player may traverse back and forth between challenges - "time_limit" => 0, // no time limit; otherwise, this field indicates time limit in seconds - "repeatable" => false // this test can be taken multiple times - ]; - - public const CURRENT_GAME_VERSION = 2; // MUST BE INCREMENTED!! - - // -------- - private int $id; // Game's ID - private string $name; // Game's name - private string $owner; // Game's owner - private array $contributors; // Contributors to the game - private string $description; // Game's description - private bool $gameFileIsPresent; // Indicates if game CSV is in place - private array $properties; // Collection of several game properties - private bool $public; // Is this game publicly available? - private string $publicId; // Public-accessible ID - private int $VERSION; // Game representation version (used during updates) - private GameMgr $gameMgr; // Game manager managing this instance - private bool $challengesLoaded; // Indicates if challenges have been fetched - private array $challenges; // Challenges - - // ------- - - static public function genPublicId(): string - { - return uniqid("p"); - } - - // ------- - - static private function patchUpGameData(array &$a) : void - { - $version = $a["version"] ?? 0; - if ($version < 2) { // update to game version 2 - if (!key_exists("public_id", $a)) { - $a["public"] = false; - $a["public_id"] = self::genPublicId(); - } - - $a["version"] = 2; - } - - if ($version < 3) { - - return; - - //$a["version"] = 3; - } - } - - // Store modifications. - public function storeMods() : void - { - $this->gameMgr->updateGame($this); - } - - // Commit modifications. - function commitMods(): void - { - //$this->patchUpGameDate(); - parent::commitMods(); - } - - // Load game challenges. - public function loadChallenges(): void - { - if ($this->isGameFileIsPresent() && !$this->challengesLoaded) { // load if file is present - $this->challenges = json_decode(file_get_contents($this->getGameFile()), true); - } - } - - // Save challenges. - public function saveChallenges(): void - { - file_put_contents($this->getGameFile(), json_encode($this->challenges)); // store challenges in JSON-format - } - - // ------- - - function __construct(GameMgr &$gameMgr, string $name, string $description = "", int $id = -1, string $owner = "", - array $contributors = [], bool $gameFileIsPresent = false, array $properties = [], - bool $public = false, string $publicId = "", int $version = 2) - { - parent::__construct(); - - $this->challengesLoaded = false; - - $this->gameMgr = $gameMgr; - $this->id = $id; - $this->name = $name; - $this->description = $description; - $this->owner = $owner; - $this->contributors = $contributors; - $this->gameFileIsPresent = $gameFileIsPresent; - $this->properties = $properties; - $this->public = $public; - $this->publicId = $publicId; - $this->VERSION = $version; - $this->challenges = []; - } - - // Create game from array representation. - static function fromArray(GameMgr &$gameMgr, array $a): Game - { - $id = $a["_id"] ?? -1; - self::patchUpGameData($a); - return new Game($gameMgr, $a["name"], $a["description"], $id, $a["owner"], $a["contributors"], - $a["game_file_present"], $a["properties"], $a["public"], $a["public_id"], $a["version"]); - } - - const OMIT_ADVANCED_FIELDS = ["contributors", "game_file_is_present", "properties", "public", "public_id", "version"]; - - // Convert game to array representation. - function toArray(array $omit = []): array - { - $a = [ - "_id" => $this->id, - "name" => $this->name, - "description" => $this->description, - "owner" => $this->owner, - "contributors" => $this->contributors, - "game_file_present" => $this->gameFileIsPresent, - "properties" => $this->properties, - "public" => $this->public, - "public_id" => $this->publicId, - "version" => $this->VERSION, - ]; - - foreach ($omit as $field) { - unset($a[$field]); - } - - return $a; - } - - // Export challenges to a CSV file. TODO: ez csak a feleletválasztóshoz lesz jó - function exportChallengesToCSV(&$f): void - { - // load challenges - $this->loadChallenges(); - - // populate CSV file - foreach ($this->challenges as $ch) { - $csvline = [ - $ch["question"], - $ch["image_url"], - ]; - $csvline = array_merge($csvline, $ch["answers"]); - fputcsv($f, $csvline); - } - } - - // Get game directory NAME with path. Does not check if the game directory exists or not. - function getGameDir(): string - { - return GAMEMEDIA_DIR . DIRECTORY_SEPARATOR . $this->getId(); - } - - // Get game file NAME with path. Does not check whether the game file is in place or not. - function getGameFile(): string - { - return GAMEMEDIA_DIR . DIRECTORY_SEPARATOR . $this->getId() . DIRECTORY_SEPARATOR . GAME_FILE; - } - - // Is the given user the owner of the game? - function isUserOwner(string $nickname): bool - { - return $this->owner === $nickname; - } - - // Is the given user a contributor of the game? - function isUserContributor(string $nickname): bool - { - return in_array($nickname, $this->contributors); - } - - // Is user contributor or owner? - function isUserContributorOrOwner(string $nickname): bool - { - return $this->isUserContributor($nickname) || $this->isUserOwner($nickname); - } - - const CSV_ENCODINGS = ["UTF-8", "Windows-1252"]; - - // Import challenges from a CSV table. TODO: ez csak a feleletválasztós betöltésére lesz jó - function importChallengesFromCSV(string $csv_path): array - { - // convert text encoding into UTF-8 - $data = file_get_contents($csv_path); - $encoding = "UNKNOWN"; - foreach (self::CSV_ENCODINGS as $enc) { // detect encoding - if (mb_check_encoding($data, $enc)) { - $encoding = $enc; - break; - } - } - - if ($encoding !== "UNKNOWN") { // if encoding has been detected successfully - $data = mb_convert_encoding($data, "UTF-8", $encoding); - file_put_contents($csv_path, $data); - } - - // clear challenges - $this->challenges = []; - - // load filled CSV file - $f = fopen($csv_path, "r"); - if (!$f) { // failed to open file - return ["n" => 0, "encoding" => $encoding]; - } - while ($csvline = fgetcsv($f)) { - // skip empty lines - if (trim(implode("", $csvline)) === "") { - continue; - } - if (count($csvline) >= 3) { - // construct challenge record - $ch = [ - "question" => trim($csvline[0]), - "image_url" => trim($csvline[1]), - "correct_answer" => trim($csvline[2]), - "answers" => array_filter(array_slice($csvline, 2), function ($v) { - return trim($v) !== ""; - }) - ]; - - // if image is attached to the challenge, then give a random name to the image - if ($ch["image_url"] !== "") { - $old_img_name = $ch["image_url"]; - $ext = pathinfo($old_img_name, PATHINFO_EXTENSION); - $ext = ($ext !== "") ? ("." . $ext) : $ext; - $new_img_name = uniqid("img_", true) . $ext; - $ch["image_url"] = $new_img_name; - - // rename the actual file - $old_img_path = $this->getGameDir() . DIRECTORY_SEPARATOR . $old_img_name; - $new_img_path = $this->getGameDir() . DIRECTORY_SEPARATOR . $new_img_name; - rename($old_img_path, $new_img_path); - } - - // store the challenge - $this->challenges[] = $ch; - } - } - fclose($f); - - // save challenges - $this->saveChallenges(); - - // update game with game file present - $this->gameFileIsPresent = true; - - // store modifications - $this->commitMods(); - - return ["n" => count($this->challenges), "encoding" => $encoding]; - } - - // --------- - - public function getName(): string - { - return $this->name; - } - - public function setName(string $name): void - { - $this->name = $name; - } - - public function getOwner(): string - { - return $this->owner; - } - - public function setOwner(string $owner): void - { - $this->owner = $owner; - } - - public function getContributors(): array - { - return $this->contributors; - } - - public function setContributors(array $contributors): void - { - $this->contributors = $contributors; - } - - public function getDescription(): string - { - return $this->description; - } - - public function setDescription(string $description): void - { - $this->description = $description; - } - - public function getId(): int - { - return $this->id; - } - - public function isGameFileIsPresent(): bool - { - return $this->gameFileIsPresent; - } - - function setProperties(array $properties): void { - $this->properties = $properties; - $this->commitMods(); - } - - public function& getProperties(): array - { - return $this->properties; - } - - public function isPublic(): bool - { - return $this->public; - } - - public function getPublicId(): string - { - return $this->publicId; - } - - public function setPublic(bool $public) : void { - $this->public = $public; - $this->commitMods(); - } - - public function getChallenges(): array - { - $this->loadChallenges(); - return $this->challenges; - } -} +require_once "Game.php"; class GameMgr { @@ -383,7 +39,7 @@ class GameMgr } function addGame(string $name, string $owner, string $description, array $properties = Game::DEFAULT_GAME_PROPERTIES, - array $contributors = [], array $challenges = []): bool + array $contributors = [], array $tasks = []): bool { $game_data = [ "name" => $name, @@ -403,7 +59,7 @@ class GameMgr $game = Game::fromArray($this, $game_data); $current_game_media_dir = $game->getGameDir(); mkdir($current_game_media_dir); - $game->saveChallenges(); + $game->saveTasks(); return true; } @@ -466,5 +122,22 @@ class GameMgr $gameIds = array_map(fn($r) => $r["name"] . "#" . $r["_id"] ,$a); } + function upgradeGames(array $ids = []): void + { + $a = []; + if ($ids === []) { + $a = $this->db->findAll(); + } else { + $a = $this->db->findBy(["_id", "IN", $ids]); + } + + foreach ($a as $g) { + $game = Game::fromArray($this, $g); + $game->loadTasks(); + $game->saveTasks(); + $game->storeMods(); + } + } + // ------- } diff --git a/class/Group.php b/class/Group.php new file mode 100644 index 0000000..c96f4d6 --- /dev/null +++ b/class/Group.php @@ -0,0 +1,226 @@ +groupMgr->updateGroup($this); + } + + // -------------- + + function __construct(GroupMgr &$groupMgr, string $name, string $description, string $owner, int $id = -1, bool $unique = true, array $editors = [], array $members = [], array $games = []) + { + parent::__construct(); + + $this->_id = $id; + $this->name = $name; + $this->unique = $unique; + $this->description = $description; + $this->owner = $owner; + $this->editors = $editors; + $this->members = $members; + $this->games = $games; + $this->groupMgr = &$groupMgr; + } + + // Create Group from array + static function fromArray(GroupMgr &$groupMgr, array $a): Group + { + $id = $a["_id"] ?? -1; + return new Group($groupMgr, $a["groupname"], $a["description"], $a["owner"], $id, $a["unique"], $a["editors"], $a["users"], $a["games"]); + } + + // Convert Group to array + function toArray(array $omit = []): array + { + $a = [ + "_id" => $this->_id, + "groupname" => $this->name, + "unique" => $this->unique, + "description" => $this->description, + "owner" => $this->owner, + "editors" => $this->editors, + "users" => $this->members, + "games" => $this->games + ]; + + foreach ($omit as $field) { + unset($a[$field]); + } + + return $a; + } + + // Get group's ID. + function getID(): int + { + return $this->_id; + } + + // Get group's name. + function getName(): string + { + return $this->name; + } + + // Set group's name. + function setName(string $name): void + { + $this->name = $name; + $this->storeMods(); + } + + // Tell if group is unique + function isUnique(): bool + { + return $this->unique; + } + + // Get group's description. + function getDescription(): string + { + return $this->description; + } + + // Set group's description. + function setDescription(string $description): void + { + $this->description = $description; + $this->storeMods(); + } + + // Get group's owner. + function getOwner(): string + { + return $this->owner; + } + + // Set group's owner. + function setOwner(string $owner): void + { + $this->owner = $owner; + $this->storeMods(); + } + + // Get list of editors. + function getEditors(): array + { + return $this->editors; + } + + // Set editors. + function setEditors(array $editors): void + { + $this->editors = $editors; + $this->storeMods(); + } + + // Get group members. + function getMembers(): array + { + return $this->members; + } + + // Set group members. + function setMembers(array $members): void + { + $this->members = $members; + $this->storeMods(); + } + + // Get games. + function getGames(): array + { + return $this->games; + } + + // Set games. + function setGames(array $games): void + { + $this->games = $games; + $this->storeMods(); + } + + // Include/exclude members. + function changeMembers(array $nicknames_add, array $nicknames_remove): void + { + foreach ($nicknames_add as $nickname) { // add members + alter_array_contents($this->members, $nickname, null); + } + foreach ($nicknames_remove as $nickname) { // remove members + alter_array_contents($this->members, null, $nickname); // delete from members + alter_array_contents($this->editors, null, $nickname); // delete from editors + } + + $this->storeMods(); // store changes + } + + // Add members + function addMembers(array $nicknames) : void { + $this->changeMembers($nicknames, []); + } + + // Remove members + function removeMembers(array $nicknames) : void { + $this->changeMembers([], $nicknames); + } + + // Include/exclude games. + function changeGames(array $gameids_add, array $gameids_remove): void + { + foreach ($gameids_add as $gameid) { // add games + alter_array_contents($this->games, $gameid, null); + } + foreach ($gameids_remove as $gameid) { // remove games + alter_array_contents($this->games, null, $gameid); + } + + $this->storeMods(); // store changes + } + + // Returns whether the user is an editor of this group. + function isUserEditor(string $nickname): bool + { + return in_array($nickname, $this->editors); + } + + // Returns whether the user is an editor or the owner of the group. + function isUserContributor(string $nickname): bool + { + return $this->isUserEditor($nickname) || ($this->owner === $nickname); + } + + // Returns if user is member of the group. + function isMember(string $nickname): bool + { + return in_array($nickname, $this->members); + } + + // Return if game is assigned to this group. + function isGameAssigned(string $gameid): bool + { + return in_array($gameid, $this->games); + } + + // Get groups unique name. + function getUniqueName(): string + { + return $this->name . ($this->unique ? "" : ("#" . $this->_id)); + } +} \ No newline at end of file diff --git a/class/GroupMgr.php b/class/GroupMgr.php index a557dea..f43af6c 100644 --- a/class/GroupMgr.php +++ b/class/GroupMgr.php @@ -6,228 +6,7 @@ require_once "AutoStoring.php"; require_once "privilege_levels.php"; -class Group extends AutoStoring -{ - private int $_id; // Group's ID (assigned by SleekDB) - private string $name; // Group's name - private bool $unique; // Indicates if name is unique or not - private string $owner; // Group owner's nickname - private string $description; // Group description - private array $editors; // Nicknames of users able to manage the group - private array $members; // Nickname of group members - private array $games; // Game IDs assigned to this group - private GroupMgr $groupMgr; // Reference to GroupMgr object managing this group - - // -------------- - - // store modifications to the database - public function storeMods() : void - { - $this->groupMgr->updateGroup($this); - } - - // -------------- - - function __construct(GroupMgr &$groupMgr, string $name, string $description, string $owner, int $id = -1, bool $unique = true, array $editors = [], array $members = [], array $games = []) - { - parent::__construct(); - - $this->_id = $id; - $this->name = $name; - $this->unique = $unique; - $this->description = $description; - $this->owner = $owner; - $this->editors = $editors; - $this->members = $members; - $this->games = $games; - $this->groupMgr = &$groupMgr; - } - - // Create Group from array - static function fromArray(GroupMgr &$groupMgr, array $a): Group - { - $id = $a["_id"] ?? -1; - return new Group($groupMgr, $a["groupname"], $a["description"], $a["owner"], $id, $a["unique"], $a["editors"], $a["users"], $a["games"]); - } - - // Convert Group to array - function toArray(array $omit = []): array - { - $a = [ - "_id" => $this->_id, - "groupname" => $this->name, - "unique" => $this->unique, - "description" => $this->description, - "owner" => $this->owner, - "editors" => $this->editors, - "users" => $this->members, - "games" => $this->games - ]; - - foreach ($omit as $field) { - unset($a[$field]); - } - - return $a; - } - - // Get group's ID. - function getID(): int - { - return $this->_id; - } - - // Get group's name. - function getName(): string - { - return $this->name; - } - - // Set group's name. - function setName(string $name): void - { - $this->name = $name; - $this->storeMods(); - } - - // Tell if group is unique - function isUnique(): bool - { - return $this->unique; - } - - // Get group's description. - function getDescription(): string - { - return $this->description; - } - - // Set group's description. - function setDescription(string $description): void - { - $this->description = $description; - $this->storeMods(); - } - - // Get group's owner. - function getOwner(): string - { - return $this->owner; - } - - // Set group's owner. - function setOwner(string $owner): void - { - $this->owner = $owner; - $this->storeMods(); - } - - // Get list of editors. - function getEditors(): array - { - return $this->editors; - } - - // Set editors. - function setEditors(array $editors): void - { - $this->editors = $editors; - $this->storeMods(); - } - - // Get group members. - function getMembers(): array - { - return $this->members; - } - - // Set group members. - function setMembers(array $members): void - { - $this->members = $members; - $this->storeMods(); - } - - // Get games. - function getGames(): array - { - return $this->games; - } - - // Set games. - function setGames(array $games): void - { - $this->games = $games; - $this->storeMods(); - } - - // Include/exclude members. - function changeMembers(array $nicknames_add, array $nicknames_remove): void - { - foreach ($nicknames_add as $nickname) { // add members - alter_array_contents($this->members, $nickname, null); - } - foreach ($nicknames_remove as $nickname) { // remove members - alter_array_contents($this->members, null, $nickname); // delete from members - alter_array_contents($this->editors, null, $nickname); // delete from editors - } - - $this->storeMods(); // store changes - } - - // Add members - function addMembers(array $nicknames) : void { - $this->changeMembers($nicknames, []); - } - - // Remove members - function removeMembers(array $nicknames) : void { - $this->changeMembers([], $nicknames); - } - - // Include/exclude games. - function changeGames(array $gameids_add, array $gameids_remove): void - { - foreach ($gameids_add as $gameid) { // add games - alter_array_contents($this->games, $gameid, null); - } - foreach ($gameids_remove as $gameid) { // remove games - alter_array_contents($this->games, null, $gameid); - } - - $this->storeMods(); // store changes - } - - // Returns whether the user is an editor of this group. - function isUserEditor(string $nickname): bool - { - return in_array($nickname, $this->editors); - } - - // Returns whether the user is an editor or the owner of the group. - function isUserContributor(string $nickname): bool - { - return $this->isUserEditor($nickname) || ($this->owner === $nickname); - } - - // Returns if user is member of the group. - function isMember(string $nickname): bool - { - return in_array($nickname, $this->members); - } - - // Return if game is assigned to this group. - function isGameAssigned(string $gameid): bool - { - return in_array($gameid, $this->games); - } - - // Get groups unique name. - function getUniqueName(): string - { - return $this->name . ($this->unique ? "" : ("#" . $this->_id)); - } -} +require_once "Group.php"; class GroupMgr { diff --git a/class/LogicFunction.php b/class/LogicFunction.php new file mode 100644 index 0000000..c677f1a --- /dev/null +++ b/class/LogicFunction.php @@ -0,0 +1,153 @@ +input_vars = $input_vars; + $this->verilog_form = $verilog_form; + $this->tex_form = $tex_form; + } + + public function getTruthTable(): array { + $tt = []; + + $N = count($this->input_vars); + $M = pow(2, $N); + + $exp_lang = new ExpressionLanguage(); + + $vars = []; + foreach ($this->input_vars as $var) { + $vars[$var] = 0; + } + + $cooked_form = str_replace(["&", "|", "~"], ["&&", "||", "!"], $this->verilog_form); + printf("Cooked: %s\n", $cooked_form); + + for ($i = 0; $i < $M; $i++) { + for ($k = 0; $k < $N; $k++) { + $vars[$this->input_vars[$k]] = (($i >> ($N - $k - 1)) & 1) === 1; + printf("%d ", $vars[$this->input_vars[$k]]); + } + $out = $exp_lang->evaluate($cooked_form, $vars); + printf("%d\n", $out); + $tt[] = $out; + } + + return $tt; + } + + public static function genRandom(array $input_vars, int $min_depth = 2, int $max_depth = 3): LogicFunction + { + function genTerm(array $vars, int $ftn, int $tn, int $mind, int $maxd, bool $top = true, int $opindex = 1): array + { + $verilog_term = ""; + $tex_term = ""; + $m = max($ftn, random_int(1, $tn)); + if ((($maxd === 0) || ($m === 1)) && ($ftn === 0)) { + $neg = random_int(0, 1) === 1; + $var = $vars[array_rand($vars, 1)]; + $verilog_term = ($neg ? "~" : "") . $var; + $tex_term = $neg ? ("\\overline{" . $var . "}") : $var; + } else { + $depth = random_int(0, max(0, $maxd - 1)); + + $verilog_ops = [" & ", " | "]; + $tex_ops = ["", " | "]; + + $verilog_op = $verilog_ops[$opindex]; + $tex_op = $tex_ops[$opindex]; + + $verilog_term = !$top ? "(" : ""; + $tex_term = !$top ? "\\left(" : ""; + + $nextopindex = ($opindex === 0) ? 1 : 0; + + for ($i = 0; $i < $m; $i++) { + $term = genTerm($vars, (($mind - 1) > 0) ? $ftn : 0, $tn, $mind - 1, $depth, false, $nextopindex); + $verilog_term .= $term["verilog"]; + $tex_term .= $term["tex"]; + if ($i < $m - 1) { + $verilog_term .= $verilog_op; + $tex_term .= $tex_op; + } + } + $verilog_term .= !$top ? ")" : ""; + $tex_term .= !$top ? "\\right)" : ""; + } + return ["verilog" => $verilog_term, "tex" => $tex_term]; + } + + $term = genTerm($input_vars, count($input_vars), count($input_vars), $min_depth, $max_depth); + + return new LogicFunction($input_vars, $term["verilog"], $term["tex"]); + + } + + public static function genRandomDF($input_vars): LogicFunction + { + $N = count($input_vars); + $states = pow(2, $N); + + $verilog_term = ""; + $tex_term = ""; + for ($i = 0; $i < $states; $i++) { + + $verilog_inside = ""; + $tex_inside = ""; + + $omit = random_int(0, 1); // omit the variable or not? + + if (!$omit) { + for ($j = 0; $j < $N; $j++) { + $neg = !($i & (1 << $j)); // is it an inverted variable? + $term = $input_vars[$j]; + if ($neg) { + $verilog_inside .= "~" . $term; + $tex_inside .= "\\overline{" . $term . "}"; + } else { + $verilog_inside .= $term; + $tex_inside .= $term; + } + + if ($j < ($N - 1)) { + $verilog_inside .= " & "; + $tex_inside .= ""; + } + } + } + + //$verilog_inside = rtrim($verilog_inside, "&"); + //$tex_inside = rtrim($tex_inside, "\\&"); + + if ($verilog_inside !== "") { + $verilog_term .= "("; + $tex_term .= "\\left("; + + $verilog_term .= $verilog_inside; + $tex_term .= $tex_inside; + + $verilog_term .= ")"; + $tex_term .= "\\right)"; + + if (($i < ($states - 1)) && !$omit) { + $verilog_term .= " | "; + $tex_term .= " | "; + } + } + } + + $verilog_term = rtrim($verilog_term, "| "); + $tex_term = rtrim($tex_term, "| "); + + + return new LogicFunction($input_vars, $verilog_term, $tex_term); + } +} \ No newline at end of file diff --git a/class/ReportBuilder.php b/class/ReportBuilder.php index 680cd04..8dd3889 100644 --- a/class/ReportBuilder.php +++ b/class/ReportBuilder.php @@ -79,7 +79,7 @@ class Answer } } -class ChallengeReport +class TaskReport { private string $question; private array $answers; @@ -143,21 +143,21 @@ class ChallengeReport class ReportSection { private string $title; - private array $challenges; + private array $tasks; private function getNumberOfSubmissions() : int { - return count($this->challenges) > 0 ? $this->challenges[0]->getSubmissionCount() : 0; + return count($this->tasks) > 0 ? $this->tasks[0]->getSubmissionCount() : 0; } - function __construct(string $title, array $challenges) + function __construct(string $title, array $tasks) { $this->title = $title; - $this->challenges = array_map(fn($ch) => new ChallengeReport($ch), $challenges); + $this->tasks = array_map(fn($ch) => new TaskReport($ch), $tasks); } - function getChallenges(): array + function getTasks(): array { - return $this->challenges; + return $this->tasks; } function getTitle(): string @@ -169,8 +169,8 @@ class ReportSection function genTeX(): string { $tex = "\\begin{quiz}{" . $this->title . "}{" . $this->getNumberOfSubmissions() . "}\n"; - foreach ($this->challenges as $challenge) { - $tex .= $challenge->genTeX(); + foreach ($this->tasks as $task) { + $tex .= $task->genTeX(); } $tex .= "\\end{quiz}\n"; return $tex; diff --git a/class/Task.php b/class/Task.php new file mode 100644 index 0000000..5b3af0b --- /dev/null +++ b/class/Task.php @@ -0,0 +1,144 @@ +type = $type; + $this->is_template = $a["is_template"] ?? false; + $this->max_mark = $a["max_mark"] ?? 1.0; + $this->question = $a["question"] ?? ""; + $this->flags = $a["flags"] ?? []; + $this->player_answer = $a["player_answer"] ?? null; + $this->correct_answer = $a["correct_answer"] ?? null; + } + + function setQuestion(string $question): void + { + $this->question = $question; + } + + function getQuestion(): string + { + return $this->question; + } + + // save answer + function saveAnswer(mixed $ans): bool + { + $this->player_answer = $ans; + return true; + } + + // clear answer + function clearAnswer(): void + { + $this->player_answer = ""; + } + + // set task type + function setType(string $type): void { + $this->type = $type; + } + + // get task type + function getType(): string + { + return $this->type; + } + + function setMaxMark(float $max_mark): void + { + $this->max_mark = $max_mark; + } + + function getMaxMark(): float + { + return $this->max_mark; + } + + function getMark(): float + { + return 1.0; + } + + function toArray(): array + { + $a = [ + "type" => $this->type, + "question" => $this->question, + "max_mark" => $this->max_mark, + "is_template" => $this->is_template, + "flags" => $this->flags, + "correct_answer" => $this->correct_answer, + ]; + + if (!$this->isTemplate()) { + $a["player_answer"] = $this->player_answer; + } + + return $a; + } + + function jsonSerialize(): mixed + { + return $this->toArray(); + } + + function setTemplate(bool $is_template): void + { + $this->is_template = $is_template; + } + + function isTemplate(): bool + { + return $this->is_template; + } + + function getFlags(): array + { + return $this->flags; + } + + function setFlags(array $flags): void + { + $this->flags = $flags; + } + + function hasFlag(string $flag): bool + { + return in_array($flag, $this->flags); + } + + function getPlayerAnswer(): mixed { + return $this->player_answer; + } + +// /* FIXME: ez ugyanaz, mint a saveAnswer() */ +// function setPlayerAnswer(mixed $player_answer): void { +// $this->player_answer = $player_answer; +// } + + function setCorrectAnswer(mixed $correct_answer): void + { + $this->correct_answer = $correct_answer; + } + + function getCorrectAnswer(): mixed + { + return $this->correct_answer; + } + + function randomize(): void + { + return; + } +} \ No newline at end of file diff --git a/class/TaskFactory.php b/class/TaskFactory.php new file mode 100644 index 0000000..d88ccc1 --- /dev/null +++ b/class/TaskFactory.php @@ -0,0 +1,36 @@ +setType("numberconversion"); + + // get instruction word + $this->instruction = strtolower(trim($a["instruction"] ?? "10u:2->2u:4")); + + // expand it + $pattern = "/([0-9]+)([suc]):([0-9]+)->([0-9]+)([suc]):([0-9]+)/"; + preg_match($pattern, $this->instruction, $matches); + + // if the instruction was meaningful + if (count($matches) == 7) { + $this->src_base = (int)$matches[1]; + $this->src_rep = $matches[2]; + $this->src_n_digits = $matches[3]; + $this->dst_base = (int)$matches[4]; + $this->dst_rep = $matches[5]; + $this->dst_n_digits = $matches[6]; + } else { // no valid instruction word has been passed + $this->src_base = 10; + $this->src_rep = "u"; + $this->src_n_digits = 2; + $this->dst_base = 2; + $this->dst_rep = "u"; + $this->dst_n_digits = 4; + + $this->instruction = $this->src_base . $this->src_rep . ":" . $this->src_n_digits . "->" . $this->dst_base . $this->dst_rep . ":" . $this->dst_n_digits; + } + + $this->source = $a["source"] ?? "---"; + $this->correct_answer = $a["correct_answer"] ?? "---"; + } + + public function toArray(): array + { + $a = parent::toArray(); + + $a["instruction"] = $this->instruction; + $a["source"] = $this->source; + $a["correct_answer"] = $this->correct_answer; + + return $a; + } + + private static function extend(string $num, int $base, int $exnd, bool $comp): string + { + $fd = (int)(base_convert($num[0], $base, 10)); // get first digit as a number + $extd = (string)(($comp && ($fd >= ($base / 2))) ? ($base - 1) : 0); // get the extension digit + return str_pad((string)($num), $extd, $extd, STR_PAD_LEFT); // extend to the left + } + + private static function complement(string $num, int $base): string + { + // convert to an integer + $M = (int)(base_convert($num, $base, 10)); + + // check if the highest digit is less than the half of the base, if not, add a zero prefix + $fd = (int)(base_convert($num[0], $base, 10)); + + // create the basis for forming the complement + $H = (string)($base - 1); + $K_str = (int)(str_repeat($H, strlen($num))); + if ($fd >= ($base / 2)) { // if one more digit is needed... + $K_str = $H . $K_str; // prepend with a zero digit + } + + // convert to integer + $K = (int)(base_convert($K_str, $base, 10)); + + // form the base's complement + $C = $K - $M + 1; + + // convert to the final base + return base_convert((string)$C, 10, $base); + } + + private static function changeRepresentation(int $num, int $base, string $rep, int $digits): string { + $neg = $num < 0; // store if the value is negative + $numa_str = (string)(abs($num)); // create the absolute value as a string + $numa_str = base_convert($numa_str, 10, $base); // convert to specific base + if ($neg) { + if ($rep === "s") { + $numa_str = self::extend($numa_str, $base, $digits, false); + $numa_str = "-" . $numa_str; + } else if ($rep === "c") { + $numa_str = self::complement($numa_str, $rep); + $numa_str = self::extend($numa_str, $base, $digits, true); + } + } else { + $numa_str = self::extend($numa_str, $base, $digits, false); + } + return $numa_str; + } + + public function randomize(): void + { + // validate representation marks + $invalid = in_array($this->src_rep . $this->dst_rep, ["us", "su"]); + if ($invalid) { // fix invalid representation pairs + $this->dst_rep = "u"; + $this->src_rep = "u"; + } + + // specify the range + $max = 1; + $min = 0; + switch ($this->dst_rep) { + case "u": + $max = pow($this->dst_base, $this->dst_n_digits) - 1; + $min = 0; + break; + case "s": + $max = pow($this->dst_base, $this->dst_n_digits) - 1; + $min = -$max; + break; + case "c": + $max = pow($this->dst_base, $this->dst_n_digits - 1) - 1; + $min = -($max + 1); + break; + } + + // randomize a value + $m = random_int($min, $max); + + // create the question and the answer + $this->correct_answer = self::changeRepresentation($m, $this->dst_base, $this->dst_rep, $this->dst_n_digits); + $this->source = self::changeRepresentation($m, $this->src_base, $this->src_rep, $this->src_n_digits); + } + + public function getMark(): float + { + if ($this->hasFlag("acceptwithoutleadingzeros")) { + return (ltrim($this->player_answer, " 0") === ltrim($this->correct_answer, "0")) ? 1.0 : 0.0; + } else { + return (trim($this->player_answer) === trim($this->correct_answer)) ? 1.0 : 0.0; + } + } +} \ No newline at end of file diff --git a/class/Tasks/OpenEndedTask.php b/class/Tasks/OpenEndedTask.php new file mode 100644 index 0000000..50a353d --- /dev/null +++ b/class/Tasks/OpenEndedTask.php @@ -0,0 +1,63 @@ +correct_answer = $a["correct_answer"] ?? null; + $this->setMaxMark(1.0); + } + + public function addCorrectAnswer(string $ca): void { + $this->correct_answer[] = $ca; + } + + public function saveAnswer(mixed $ans): bool + { + // collect transformations + $transform_fns = []; + foreach ($this->flags as $flag) { + switch ($flag) { + case "makeuppercase": + $transform_fns[] = "strtoupper"; + break; + case "makelowercase": + $transform_fns[] = "strtolower"; + break; + case "removespaces": + $transform_fns[] = fn($str) => str_replace(" ", "", $str); + break; + } + } + + // run the transformations + foreach ($transform_fns as $tfn) { + $ans = $tfn($ans); + } + + // save the answer + $this->player_answer = $ans; + + return true; + } + + public function clearAnswer(): void + { + $this->player_answer = ""; + } + + public function getMark(): float + { + return in_array($this->player_answer, $this->correct_answer) ? 1.0 : 0.0; + } + + function toArray(): array { + $a = parent::toArray(); + $a["correct_answer"] = $this->correct_answer; + return $a; + } +} \ No newline at end of file diff --git a/class/Tasks/PicturedTask.php b/class/Tasks/PicturedTask.php new file mode 100644 index 0000000..721d18a --- /dev/null +++ b/class/Tasks/PicturedTask.php @@ -0,0 +1,31 @@ +image_url = $a["image_url"] ?? ""; + } + + function setImageUrl(string $image_url): void + { + $this->image_url = $image_url; + } + + function getImageUrl(): string + { + return $this->image_url; + } + + function toArray(): array + { + $a = parent::toArray(); + $a["image_url"] = $this->image_url; + return $a; + } +} \ No newline at end of file diff --git a/class/Tasks/SingleChoiceTask.php b/class/Tasks/SingleChoiceTask.php new file mode 100644 index 0000000..ff0aecc --- /dev/null +++ b/class/Tasks/SingleChoiceTask.php @@ -0,0 +1,85 @@ +answers = $a["answers"] ?? []; + $ca = $a["correct_answer"] ?? -1; + if (gettype($ca) === "string") { // backward compatibility + $this->correct_answer = array_search($a["correct_answer"], $this->answers); + } else { + $this->correct_answer = (int)($ca); + } + $this->player_answer = $a["player_answer"] ?? -1; + } + + function addAnswer(string $answer): void + { + $this->answers[] = $answer; + } + + function getAnswers(): array + { + return $this->answers; + } + + private function isAnswerIdInsideBounds($ansid): bool + { + return ($ansid >= 0) && ($ansid <= count($this->answers)); + } + + function saveAnswer(mixed $ans): bool + { + $ansidx = (int)($ans); // cast answer to integer as it is a number + if ($this->isAnswerIdInsideBounds($ansidx)) { + $this->player_answer = $ansidx; + return true; + } + + return false; + } + + function clearAnswer(): void + { + $this->player_answer = -1; + } + + public function getMark(): float + { + return ($this->player_answer == $this->correct_answer) ? 1.0 : 0.0; + } + + function toArray(): array + { + $a = parent::toArray(); + $a["answers"] = $this->answers; + $a["correct_answer"] = $this->correct_answer; + return $a; + } + + function randomize(): void{ + $ordering = range(0, count($this->answers) - 1); // create an ordered range + shuffle($ordering); // shuffle indices + $shans = []; + $ca_remapped = false; // avoid remapping the correct answer multiple times + for ($i = 0; $i < count($this->answers); $i++) { // shuffle answers + $orig_pos = $ordering[$i]; + $shans[] = $this->answers[$orig_pos]; + if ((!$ca_remapped) && ($orig_pos == $this->correct_answer)) { + $this->correct_answer = $i; + $ca_remapped = true; + } + + } + $this->answers = $shans; + } +} \ No newline at end of file diff --git a/class/Tasks/TruthTableTask.php b/class/Tasks/TruthTableTask.php new file mode 100644 index 0000000..987449e --- /dev/null +++ b/class/Tasks/TruthTableTask.php @@ -0,0 +1,18 @@ +setType("verilog"); + } + + public function randomize(): void + { + return parent::randomize(); // TODO: Change the autogenerated stub + } +} \ No newline at end of file diff --git a/class/Test.php b/class/Test.php new file mode 100644 index 0000000..be9f6a8 --- /dev/null +++ b/class/Test.php @@ -0,0 +1,251 @@ +tasks as &$task) { + $task->setTemplate(false); // the task is no longer a template + $task->randomize(); // randomize + } + } + + // ------------- + + function getMaxSumMark(): float + { + $msm = 0.0; + foreach ($this->tasks as &$task) { + $msm += $task->getMaxMark(); + } + return $msm; + } + + // ------------- + + // Store modifications. + public function storeMods(): void + { + $this->testMgr->updateTest($this); + } + + // ------------- + + // Construct new test based on Game and User objects + function __construct(TestMgr &$testMgr, Game|array &$game_array, User &$user = null) + { + parent::__construct(); + + $this->testMgr = $testMgr; + $this->id = -1; + + if (is_array($game_array)) { // populating fields from an array + $a = &$game_array; + + $this->id = $a["_id"] ?? -1; + $this->gameId = $a["gameid"]; + $this->gameName = $a["gamename"]; + $this->nickname = $a["nickname"]; + $this->state = $a["state"]; + $this->timeLimited = $a["time_limited"]; + $this->startTime = $a["start_time"]; + $this->endTime = $a["end_time"] ?? 0; + $this->endLimitTime = $a["end_limit_time"] ?? 0; + $this->repeatable = $a["repeatable"]; + $this->tasks = TaskFactory::constructFromCollection($a["challenges"]); + if (isset($a["summary"])) { + $this->summary = TestSummary::fromArray($a["summary"]); + } else { // backward compatibility + $this->summary = new TestSummary($this->getMaxSumMark(), 0); + } + } else { // populating fields from Game and User objects + $game = &$game_array; + + $this->endTime = 0; + + // Fill-in basic properties + $this->gameId = $game->getId(); + $this->gameName = $game->getName(); + $this->tasks = $game->getTasks(); + $this->preprocessTasks(); + $this->nickname = $user->getNickname(); + + $this->state = self::TEST_ONGOING; + $gp = $game->getProperties(); + $this->timeLimited = (($gp["time_limit"] ?: -1) > -1); + + $now = time(); + $this->startTime = $now; + if ($this->timeLimited) { + $this->endLimitTime = $now + $gp["time_limit"]; + } else { + $this->endLimitTime = -1; // dummy value, not used, since timeLimited is false + } + + $this->repeatable = $gp["repeatable"]; + + // Create a blank summary + $this->summary = new TestSummary($this->getMaxSumMark(), 0); + } + + // auto-conclude time-constrained test if expired + if ($this->timeLimited && $this->isOngoing() && ($this->endLimitTime <= time())) { + $this->concludeTest(); + $this->endTime = $this->endLimitTime; // date back end time to the limiting value + } + } + + // Convert test to array. + function toArray(array $omit = []): array + { + $tasks = []; + foreach ($this->tasks as &$t) { + $tasks[] = $t->toArray(); + } + + $a = [ + "_id" => $this->id, + "gameid" => $this->gameId, + "nickname" => $this->nickname, + "gamename" => $this->gameName, + "state" => $this->state, + "time_limited" => $this->timeLimited, + "start_time" => $this->startTime, + "end_time" => $this->endTime, + "end_limit_time" => $this->endLimitTime, + "repeatable" => $this->repeatable, + "challenges" => $tasks, + "summary" => $this->summary->toArray() + ]; + + // omit specific fields + foreach ($omit as $field) { + unset($a[$field]); + } + + return $a; + } + + // Get number of tasks. + function getTaskCount(): int + { + return count($this->tasks); + } + + function isTaskIdInsideBounds(int $tidx): bool { + return ($tidx >= 0) && ($tidx < $this->getTaskCount()); + } + + // Save answer. Asserting $safe prevents saving answers to a concluded test. + function saveAnswer(int $tidx, int|string $ans, bool $safe = true): bool + { + if (!$safe || $this->state === self::TEST_ONGOING) { + if ($this->isTaskIdInsideBounds($tidx)) { + $this->tasks[$tidx]->saveAnswer($ans); + $this->commitMods(); + return true; + } + } + return false; + } + + // Clear answer. + function clearAnswer(int $chidx, bool $safe = true): bool + { + if (!$safe || $this->state === self::TEST_ONGOING) { + if ($this->isTaskIdInsideBounds($chidx)) { + $this->tasks[$chidx]->clearAnswer(); + $this->commitMods(); + return true; + } + } + return false; + } + + // Conclude test. + function concludeTest(): void + { + // summarize points + $mark_sum = 0.0; + foreach ($this->tasks as &$ch) { + $mark_sum += $ch->getMark(); + } + + // set state and fill summary + $this->state = self::TEST_CONCLUDED; + $this->endTime = time(); + $this->summary->setMark($mark_sum); + + // save test + $this->commitMods(); + } + + // -------- + + public function getId(): int + { + return $this->id; + } + + public function getStartTime(): int + { + return $this->startTime; + } + + public function getEndTime(): int + { + return $this->endTime; + } + + public function getSummary(): TestSummary + { + return $this->summary; + } + + public function getNickname(): string + { + return $this->nickname; + } + + public function getGameId(): int + { + return $this->gameId; + } + + public function isConcluded(): bool + { + return $this->state === self::TEST_CONCLUDED; + } + + public function isOngoing(): bool + { + return $this->state === self::TEST_ONGOING; + } +} \ No newline at end of file diff --git a/class/TestMgr.php b/class/TestMgr.php index 329a655..1aa3d85 100644 --- a/class/TestMgr.php +++ b/class/TestMgr.php @@ -8,534 +8,7 @@ require_once "ExpressionBuilder.php"; require_once "globals.php"; -const TEST_ONGOING = "ongoing"; -const TEST_CONCLUDED = "concluded"; - -class TestSummary -{ - public int $maxMark; // Number of challenges - public int $mark; // Number of correct answers - private float $percentage; // Ratio of correct answers - - // Calculate percentage. - private function calculatePercentage(): void - { - if ($this->maxMark > 0) { - $this->percentage = $this->mark / (double)$this->maxMark * 100.0; - } else { // avoid division by zero - $this->percentage = 0.0; - } - } - - function __construct(int $challengeN, int $correctAnswerN) - { - $this->maxMark = $challengeN; - $this->mark = $correctAnswerN; - $this->calculatePercentage(); - } - - // Get challenge count. - function getMaxMark(): int - { - return $this->maxMark; - } - - // Get number of correct answers. - function getMark(): int - { - return $this->mark; - } - - function setMark(int $mark): void - { - $this->mark = $mark; - $this->calculatePercentage(); - } - - // Get ratio of correct results. - function getPercentage(): float - { - return ($this->mark * 100.0) / $this->maxMark; - } - - // Build from array. - static function fromArray(array $a): TestSummary - { - if (!isset($a["max_mark"]) || !isset($a["mark"])) { // backward compatibility - return new TestSummary($a["challenge_n"], $a["correct_answer_n"]); - } else { - return new TestSummary($a["max_mark"], $a["mark"]); - } - } - - // Convert to array. - function toArray(): array - { - return ["challenge_n" => $this->maxMark, "correct_answer_n" => $this->mark, "percentage" => $this->percentage]; - } -} - -class Challenge -{ - protected string $type; // challenge type - - protected float $max_mark; // maximum points that can be collected at this challenge - - protected bool $is_template; // this challenge is a template - - function __construct(string $type) - { - $this->type = $type; - $this->is_template = false; - $this->max_mark = 1.0; - } - - // save answer - function saveAnswer(int|string $ans): bool - { - return false; - } - - // clear answer - function clearAnswer(int|string $ans): bool - { - return false; - } - - // get challenge type - function getType(): string - { - return $this->type; - } - - function setMaxMark(float $max_mark): void - { - $this->max_mark = $max_mark; - } - - function getMaxMark(): float { - return $this->max_mark; - } - - function getMark(): float - { - return 1.0; - } - - function toArray(): array - { - return ["type" => $this->type]; - } - - function setTemplate(bool $is_template): void - { - $this->is_template = $is_template; - } - - function isTemplate(): bool - { - return $this->is_template; - } - - function randomize(): void { - return; - } -} - -class PicturedChallenge extends Challenge -{ - protected string $image_url; // the URL of the corresponding image - - function __construct(string $type, array $a = null) - { - parent::__construct($type); - $this->image_url = $a["image_url"] ?? ""; - } - - function setImageUrl(string $image_url): void - { - $this->image_url = $image_url; - } - - function getImageUrl(): string - { - return $this->image_url; - } - - function toArray(): array - { - $a = parent::toArray(); - $a["image_url"] = $this->image_url; - return $a; - } -} - -class SingleChoiceChallenge extends PicturedChallenge -{ - private string $question; // the task title - private array $answers; // possible answers - private int $correct_answer; // the single correct answer - - private int $player_answer; // answer given by the player - - // ----------------- - - function __construct(array $a = null) - { - parent::__construct("singlechoice", $a); - - $this->question = $a["question"] ?? ""; - $this->answers = $a["answers"] ?? []; - $this->correct_answer = (int)($a["correct_answer"] ?? -1); - $this->player_answer = (int)($a["player_answer"] ?? -1); - } - - function setQuestion(string $question): void - { - $this->question = $question; - } - - function getQuestion(): string - { - return $this->question; - } - - function addAnswer(string $answer): void - { - $this->answers[] = $answer; - } - - function getAnswers(): array - { - return $this->answers; - } - - function setCorrectAnswer(string $correct_answer): void - { - $this->correct_answer = $correct_answer; - } - - function getCorrectAnswer(): string - { - return $this->correct_answer; - } - - private function isAnswerIdInsideBounds($ansid): bool - { - return ($ansid >= 0) && ($ansid <= count($this->answers)); - } - - function saveAnswer(int|string $ans): bool - { - $ansidx = (int)($ans); // cast answer to integer as it is a number - if ($this->isAnswerIdInsideBounds($ansidx)) { - $this->player_answer = $ansidx; - return true; - } - - return false; - } - - function clearAnswer(int|string $ans): bool - { - $ansidx = (int)($ans); // cast answer to integer as it is a number - if ($this->isAnswerIdInsideBounds($ansidx)) { - $this->player_answer = -1; - return true; - } - - return false; - } - - public function getMark(): float - { - return ($this->player_answer == $this->correct_answer) ? 1.0 : 0.0; - } - - function toArray(): array - { - $a = parent::toArray(); - $a["question"] = $this->question; - $a["answers"] = $this->answers; - $a["correct_answer"] = $this->correct_answer; - if (!$this->isTemplate()) { - $a["player_answer"] = $this->player_answer; - } - return $a; - } - - function randomize(): void{ - //shuffle($this->answers); // shuffle answers - //$this->correct_answer = array_search($this->correct_answer, $this->answers); // remap correct answer - } -} - -class ChallengeFactory -{ - static function fromArray(array $a): Challenge|null - { - $type = $a["type"] ?? "singlechoice"; // if the type is missing, then it's a single choice challenge - switch ($type) { - case "singlechoice": - return new SingleChoiceChallenge($a); - } - - return null; - } - - static function constructFromCollection(array $c): array { - $chgs = []; - foreach ($c as $ch) { - $chgs[] = ChallengeFactory::fromArray($ch); - } - return $chgs; - } -} - -class Test extends AutoStoring -{ - const TEST_ONGOING = "ongoing"; - const TEST_CONCLUDED = "concluded"; - - // --------- - - public int $id; // ID - public int $gameId; // ID of associated game - public string $gameName; // Name of the associated game - public string $nickname; // Associated user's nickname - public string $state; // State of the test (ongoing/concluded) - public bool $timeLimited; // The user is allowed to submit the test in a given period of time. - public bool $repeatable; // Is the user allowed to take this test multiple times? - public int $startTime; // Start time (UNIX timestamp) - public int $endTime; // End time (UNIX timestamp) - public int $endLimitTime; // Time limit on test submission (UNIX timestamp) - public TestSummary $summary; // Summmary, if game has ended - public array $challenges; // Test challenges - - private TestMgr $testMgr; // Reference to TestMgr managing this Test instance - - // ------------- - - // Preprocess challenges. - private function preprocessChallenges(): void - { - foreach ($this->challenges as &$ch) { - $ch->randomize(); - } - } - - // ------------- - - function getMaxSumMark(): float - { - $msm = 0.0; - foreach ($this->challenges as &$ch) { - $msm += $ch->getMaxMark(); - } - return $msm; - } - - // ------------- - - // Store modifications. - public function storeMods(): void - { - $this->testMgr->updateTest($this); - } - - // ------------- - - // Construct new test based on Game and User objects - function __construct(TestMgr &$testMgr, Game|array &$game_array, User &$user = null) - { - parent::__construct(); - - $this->testMgr = $testMgr; - $this->id = -1; - - if (is_array($game_array)) { // populating fields from an array - $a = &$game_array; - - $this->id = $a["_id"] ?? -1; - $this->gameId = $a["gameid"]; - $this->gameName = $a["gamename"]; - $this->nickname = $a["nickname"]; - $this->state = $a["state"]; - $this->timeLimited = $a["time_limited"]; - $this->startTime = $a["start_time"]; - $this->endTime = $a["end_time"] ?? 0; - $this->endLimitTime = $a["end_limit_time"] ?? 0; - $this->repeatable = $a["repeatable"]; - $this->challenges = ChallengeFactory::constructFromCollection($a["challenges"]); - if (isset($a["summary"])) { - $this->summary = TestSummary::fromArray($a["summary"]); - } else { // backward compatibility - $this->summary = new TestSummary($this->getMaxSumMark(), 0); - } - } else { // populating fields from Game and User objects - $game = &$game_array; - - $this->endTime = 0; - - // Fill-in basic properties - $this->gameId = $game->getId(); - $this->gameName = $game->getName(); - $this->challenges = ChallengeFactory::constructFromCollection($game->getChallenges()); - $this->preprocessChallenges(); - $this->nickname = $user->getNickname(); - - $this->state = self::TEST_ONGOING; - $gp = $game->getProperties(); - $this->timeLimited = (($gp["time_limit"] ?: -1) > -1); - - $now = time(); - $this->startTime = $now; - if ($this->timeLimited) { - $this->endLimitTime = $now + $gp["time_limit"]; - } else { - $this->endLimitTime = -1; // dummy value, not used, since timeLimited is false - } - - $this->repeatable = $gp["repeatable"]; - - // Create a blank summary - $this->summary = new TestSummary($this->getMaxSumMark(), 0); - } - - // auto-conclude time-constrained test if expired - if ($this->timeLimited && $this->isOngoing() && ($this->endLimitTime <= time())) { - $this->concludeTest(); - $this->endTime = $this->endLimitTime; // date back end time to the limiting value - } - } - - // Convert test to array. - function toArray(array $omit = []): array - { - $chgs = []; - foreach ($this->challenges as $ch) { - $chgs[] = $ch->toArray(); - } - - $a = [ - "_id" => $this->id, - "gameid" => $this->gameId, - "nickname" => $this->nickname, - "gamename" => $this->gameName, - "state" => $this->state, - "time_limited" => $this->timeLimited, - "start_time" => $this->startTime, - "end_time" => $this->endTime, - "end_limit_time" => $this->endLimitTime, - "repeatable" => $this->repeatable, - "challenges" => $chgs, - "summary" => $this->summary->toArray() - ]; - - // omit specific fields - foreach ($omit as $field) { - unset($a[$field]); - } - - return $a; - } - - // Get number of challenges. - function getChallengeCount(): int - { - return count($this->challenges); - } - - function isChallengeIdInsideBounds(int $chidx): bool { - return ($chidx >= 0) && ($chidx < $this->getChallengeCount()); - } - - // Save answer. Asserting $safe prevents saving answers to a concluded test. - function saveAnswer(int $chidx, string $ans, bool $safe = true): bool - { - if (!$safe || $this->state === self::TEST_ONGOING) { - if ($this->isChallengeIdInsideBounds($chidx)) { - $this->challenges[$chidx]->saveAnswer($ans); - $this->commitMods(); - return true; - } - } - return false; - } - - // Clear answer. - function clearAnswer(int $chidx, bool $safe = true): bool - { - if (!$safe || $this->state === self::TEST_ONGOING) { - if ($this->isChallengeIdInsideBounds($chidx)) { - $this->challenges[$chidx]->clearAnswer(); - $this->commitMods(); - return true; - } - } - return false; - } - - // Conclude test. - function concludeTest(): void - { - // summarize points - $mark_sum = 0.0; - foreach ($this->challenges as &$ch) { - $mark_sum += $ch->getMark(); - } - - // set state and fill summary - $this->state = TEST_CONCLUDED; - $this->endTime = time(); - $this->summary->setMark($mark_sum); - - // save test - $this->commitMods(); - } - - // -------- - - public function getId(): int - { - return $this->id; - } - - public function getStartTime(): int - { - return $this->startTime; - } - - public function getEndTime(): int - { - return $this->endTime; - } - - public function getSummary(): TestSummary - { - return $this->summary; - } - - public function getNickname(): string - { - return $this->nickname; - } - - public function getGameId(): int - { - return $this->gameId; - } - - public function isConcluded(): bool - { - return $this->state === self::TEST_CONCLUDED; - } - - public function isOngoing(): bool - { - return $this->state === self::TEST_ONGOING; - } -} +require_once "Test.php"; class TestMgr { @@ -598,7 +71,7 @@ class TestMgr if (count($previous_tests) > 0) { // if there are previous attempts, then... fetch: // re-fetch tests, look only for ongoing - $ongoing_tests = $this->db->findBy([$fetch_criteria, "AND", ["state", "=", TEST_ONGOING]]); + $ongoing_tests = $this->db->findBy([$fetch_criteria, "AND", ["state", "=", Test::TEST_ONGOING]]); if (count($ongoing_tests) !== 0) { // if there's an ongoing test $testid = $ongoing_tests[0]["_id"]; $test = $this->getTest($testid); @@ -643,7 +116,7 @@ class TestMgr } // Get test results by game ID. - function getResultsByGameId(string $gameid, string $filter, string $orderby, bool $exclude_challenge_data, bool $best_ones_only, array ...$furtherFilters): array + function getResultsByGameId(string $gameid, string $filter, string $orderby, bool $exclude_task_data, bool $best_ones_only, array ...$furtherFilters): array { $qb = $this->db->createQueryBuilder(); $qb = $qb->where(["gameid", "=", (int)$gameid]); @@ -679,8 +152,8 @@ class TestMgr $qb->orderBy($ordering); } - // excluding challenge data - if ($exclude_challenge_data) { + // excluding task data + if ($exclude_task_data) { $qb->except(["challenges"]); } @@ -731,42 +204,42 @@ class TestMgr $qb->select(["challenges"]); $entries = $qb->getQuery()->fetch(); - $challenge_indices = []; + $task_indices = []; // count answers $aggregated = []; foreach ($entries as $entry) { - foreach ($entry["challenges"] as $challenge) { - $correct_answer = $challenge["answers"][$challenge["correct_answer"]]; - $compound = $challenge["question"] . $correct_answer . count($challenge["answers"]) . $challenge["image_url"]; + foreach ($entry["challenges"] as $task) { + $correct_answer = $task["answers"][$task["correct_answer"]]; + $compound = $task["question"] . $correct_answer . count($task["answers"]) . $task["image_url"]; $idhash = md5($compound); - // if this is a new challenge to the list... - if (!isset($challenge_indices[$idhash])) { - $challenge_indices[$idhash] = count($challenge_indices); - $challenge_info = [ // copy challenge info + // if this is a new task to the list... + if (!isset($task_indices[$idhash])) { + $task_indices[$idhash] = count($task_indices); + $task_info = [ // copy challenge info "hash" => $idhash, - "image_url" => $challenge["image_url"], - "question" => $challenge["question"], - "answers" => $challenge["answers"], + "image_url" => $task["image_url"], + "question" => $task["question"], + "answers" => $task["answers"], "correct_answer" => $correct_answer, - "player_answers" => array_fill(0, count($challenge["answers"]), 0), - "answer_count" => count($challenge["answers"]), + "player_answers" => array_fill(0, count($task["answers"]), 0), + "answer_count" => count($task["answers"]), "skipped" => 0 ]; - $aggregated[$challenge_indices[$idhash]] = $challenge_info; // insert challenge info + $aggregated[$task_indices[$idhash]] = $task_info; // insert task info } - // fetch challenge index - $challenge_idx = $challenge_indices[$idhash]; + // fetch task index + $task_idx = $task_indices[$idhash]; // add up player answer - $player_answer = trim($challenge["player_answer"]); + $player_answer = trim($task["player_answer"]); if (($player_answer !== "") && ($player_answer != -1)) { // player answered - $answer_idx = array_search($challenge["answers"][$challenge["player_answer"]], $aggregated[$challenge_idx]["answers"]); // transform player answer index to report answer index - $aggregated[$challenge_idx]["player_answers"][(int)$answer_idx]++; + $answer_idx = array_search($task["answers"][$task["player_answer"]], $aggregated[$task_idx]["answers"]); // transform player answer index to report answer index + $aggregated[$task_idx]["player_answers"][(int)$answer_idx]++; } else { // player has not answered or provided an unprocessable answer - $aggregated[$challenge_idx]["skipped"]++; + $aggregated[$task_idx]["skipped"]++; } } } @@ -786,7 +259,7 @@ class TestMgr } } - // match challenges + // match tasks return $aggregated; } @@ -811,7 +284,7 @@ class TestMgr { $query = [["time_limited", "=", true], "AND", ["end_limit_time", "<", time()]]; if ($ongoingOnly) { - $query = [...$query, "AND", ["state", "=", TEST_ONGOING]]; + $query = [...$query, "AND", ["state", "=", Test::TEST_ONGOING]]; } $qb = $this->db->createQueryBuilder(); diff --git a/class/TestSummary.php b/class/TestSummary.php new file mode 100644 index 0000000..94693df --- /dev/null +++ b/class/TestSummary.php @@ -0,0 +1,65 @@ +maxMark > 0) { + $this->percentage = $this->mark / (double)$this->maxMark * 100.0; + } else { // avoid division by zero + $this->percentage = 0.0; + } + } + + function __construct(int $taskN, int $correctAnswerN) + { + $this->maxMark = $taskN; + $this->mark = $correctAnswerN; + $this->calculatePercentage(); + } + + // Get max mark. + function getMaxMark(): int + { + return $this->maxMark; + } + + // Get mark. + function getMark(): int + { + return $this->mark; + } + + function setMark(int $mark): void + { + $this->mark = $mark; + $this->calculatePercentage(); + } + + // Get ratio of correct results. + function getPercentage(): float + { + return ($this->mark * 100.0) / $this->maxMark; + } + + // Build from array. + static function fromArray(array $a): TestSummary + { + if (!isset($a["max_mark"]) || !isset($a["mark"])) { // backward compatibility + return new TestSummary($a["challenge_n"], $a["correct_answer_n"]); + } else { + return new TestSummary($a["max_mark"], $a["mark"]); + } + } + + // Convert to array. + function toArray(): array + { + return ["challenge_n" => $this->maxMark, "correct_answer_n" => $this->mark, "percentage" => $this->percentage]; + } +} \ No newline at end of file diff --git a/class/User.php b/class/User.php new file mode 100644 index 0000000..3f02dba --- /dev/null +++ b/class/User.php @@ -0,0 +1,134 @@ +userMgr->updateUser($this); + } + + // ------------------------------------------- + + function __construct(UserMgr &$usrmgr, int $id, string $nickname = null, string $password = null, string $realname = null, string $privilege = null) + { + parent::__construct(); + + $this->id = $id; + $this->nickname = $nickname; + $this->password = $password; + $this->realname = $realname; +// $this->groups = $groups; + $this->privilege = $privilege; + + // save reference to user manager + $this->userMgr = &$usrmgr; + } + + // Create user from an array + static function fromArray(UserMgr &$usrmgr, array $a): User + { + $id = $a["_id"] ?? -1; + return new User($usrmgr, $id, $a["nickname"], $a["password"], $a["realname"], $a["privilege"]); + } + + // Convert user to array + function toArray(array $omit = []): array + { + $a = [ + "_id" => $this->id, + "nickname" => $this->nickname, + "password" => $this->password, + "realname" => $this->realname, +// "groups" => $this->groups, + "privilege" => $this->privilege + ]; + + // omit specific fields + foreach ($omit as $field) { + unset($a[$field]); + } + + return $a; + } + + // Change user password. If $safe, then $old is checked. + function changePassword(string $new, string $old, bool $safe = true): bool + { + if (!$safe || password_verify($old, $this->password)) { + $this->password = password_hash($new, PASSWORD_DEFAULT); + $this->storeMods(); // store modifications + return true; + } else { + return false; + } + } + +// // Change user groups +// function changeGroups(array $add, array $remove): void +// { +// alter_array_contents($this->groups, $add, $remove); +// $this->storeMods(); // store modifications +// } + +// // Get user's groups +// function getGroups(): array +// { +// return $this->groups; +// } + + // Set user privilege level + function setPrivilege(string $privilege): void + { + $this->privilege = ($this->nickname === QUIZMASTER_NICKNAME) ? PRIVILEGE_QUIZMASTER : $privilege; // quizmaster's privilege mustn't be tampered with + $this->storeMods(); // store modifications + } + + // Get user privilege level + function getPrivilege(): string + { + return $this->privilege; + } + + // Get user's nickname. + function getNickname(): string + { + return $this->nickname; + } + + // Set user's real name. + function setRealname(string $realname): void + { + $this->realname = $realname; + } + + // Get user's real name. + function getRealname(): string + { + return $this->realname; + } + + // Check against user credentials. + function checkPassword(string $password): bool + { + return password_verify($password, $this->password); + } + + // Has the user quizmaster privileges? + function hasQuizmasterPrivilege(): bool + { + return $this->privilege == PRIVILEGE_QUIZMASTER; + } +} \ No newline at end of file diff --git a/class/UserMgr.php b/class/UserMgr.php index 725dc42..1a1f0dc 100644 --- a/class/UserMgr.php +++ b/class/UserMgr.php @@ -8,136 +8,7 @@ require_once "AutoStoring.php"; require_once "privilege_levels.php"; -class User extends AutoStoring -{ - private int $id; // User's ID - private string $nickname; // User's nickname - private string $password; // User's password in it's encoded form or left empty - private string $realname; // User's real name displayed in their profile -// private array $groups; // User's assigned groups - private string $privilege; // User's privilege - private UserMgr $userMgr; // UserManager object governing this object. - - // ------------------------------------------- - - // Store modifications to the database. - public function storeMods(): void - { - $this->userMgr->updateUser($this); - } - - // ------------------------------------------- - - function __construct(UserMgr &$usrmgr, int $id, string $nickname = null, string $password = null, string $realname = null, string $privilege = null) - { - parent::__construct(); - - $this->id = $id; - $this->nickname = $nickname; - $this->password = $password; - $this->realname = $realname; -// $this->groups = $groups; - $this->privilege = $privilege; - - // save reference to user manager - $this->userMgr = &$usrmgr; - } - - // Create user from an array - static function fromArray(UserMgr &$usrmgr, array $a): User - { - $id = $a["_id"] ?? -1; - return new User($usrmgr, $id, $a["nickname"], $a["password"], $a["realname"], $a["privilege"]); - } - - // Convert user to array - function toArray(array $omit = []): array - { - $a = [ - "_id" => $this->id, - "nickname" => $this->nickname, - "password" => $this->password, - "realname" => $this->realname, -// "groups" => $this->groups, - "privilege" => $this->privilege - ]; - - // omit specific fields - foreach ($omit as $field) { - unset($a[$field]); - } - - return $a; - } - - // Change user password. If $safe, then $old is checked. - function changePassword(string $new, string $old, bool $safe = true): bool - { - if (!$safe || password_verify($old, $this->password)) { - $this->password = password_hash($new, PASSWORD_DEFAULT); - $this->storeMods(); // store modifications - return true; - } else { - return false; - } - } - -// // Change user groups -// function changeGroups(array $add, array $remove): void -// { -// alter_array_contents($this->groups, $add, $remove); -// $this->storeMods(); // store modifications -// } - -// // Get user's groups -// function getGroups(): array -// { -// return $this->groups; -// } - - // Set user privilege level - function setPrivilege(string $privilege): void - { - $this->privilege = ($this->nickname === QUIZMASTER_NICKNAME) ? PRIVILEGE_QUIZMASTER : $privilege; // quizmaster's privilege mustn't be tampered with - $this->storeMods(); // store modifications - } - - // Get user privilege level - function getPrivilege(): string - { - return $this->privilege; - } - - // Get user's nickname. - function getNickname(): string - { - return $this->nickname; - } - - // Set user's real name. - function setRealname(string $realname): void - { - $this->realname = $realname; - } - - // Get user's real name. - function getRealname(): string - { - return $this->realname; - } - - // Check against user credentials. - function checkPassword(string $password): bool - { - return password_verify($password, $this->password); - } - - // Has the user quizmaster privileges? - function hasQuizmasterPrivilege(): bool - { - return $this->privilege == PRIVILEGE_QUIZMASTER; - } -} +require_once "User.php"; class UserMgr { diff --git a/class/VerilogUtils.php b/class/VerilogUtils.php new file mode 100644 index 0000000..1356b5d --- /dev/null +++ b/class/VerilogUtils.php @@ -0,0 +1,9 @@ +upgradeGames(); + printf("OK!\n"); + } + break; case "get_timed_tests": { $testMgr = new TestMgr(); printf("Expired timed tests: %s\n", join(", ", $testMgr->extractExpiredTimedTestIds())); } break; + case "gen_random": + { + //$lf = LogicFunction::genRandom(["a", "b", "c"], 2, 4); + $lf = LogicFunction::genRandomDF(["a", "b", "c"]); + printf("Verilog-form: %s\nTeX-form: %s\n", $lf->verilog_form, $lf->tex_form); + $lf->getTruthTable(); + } + break; } } diff --git a/composer.json b/composer.json index 0bd6d47..3939dcd 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,8 @@ "ext-http": "*", "ext-mbstring" : "*", "ext-zip": "*", - "ext-fileinfo": "*" + "ext-fileinfo": "*", + "phpoffice/phpspreadsheet": "^5.1", + "symfony/expression-language": "^7.3" } -} \ No newline at end of file +} diff --git a/game_manager_frame.php b/game_manager_frame.php index 3cd948d..4d03230 100644 --- a/game_manager_frame.php +++ b/game_manager_frame.php @@ -71,10 +71,10 @@ if (!get_autologin_state() || (($user_data["privilege"] !== PRIVILEGE_CREATOR) & - - + + diff --git a/interface.php b/interface.php index 814b3f6..714e2c1 100644 --- a/interface.php +++ b/interface.php @@ -32,6 +32,10 @@ require_once "class/TestMgr.php"; require_once "class/ReportBuilder.php"; +require_once "vendor/autoload.php"; + +use PhpOffice\PhpSpreadsheet\Spreadsheet; + // ------------------------ $userMgr = new UserMgr(); @@ -72,7 +76,9 @@ function login(ReqHandler &$rh, array $params): string $user = $userMgr->getUser($nickname); if (($user !== null) && $user->checkPassword($password)) { - session_start(); + if (session_status() == PHP_SESSION_NONE) { + session_start(); + } $_SESSION["nickname"] = $nickname; $result = "OK"; } else { @@ -152,7 +158,11 @@ function start_or_continue_test(ReqHandler &$rh, array $params): string if ($groupMgr->doesUserAccessGame($params["gameid"], $user->getNickname())) { $game = $gameMgr->getGame($params["gameid"]); $test = $testMgr->addOrContinueTest($game, $user); - return $test->getId(); + if ($test != null) { + return $test->getId(); + } else { + return -1; + } } else { return -1; } @@ -235,10 +245,10 @@ function access_test_data(string $testid): Test|null return $test; } -function exclude_correct_answers(array &$challenges): void +function exclude_correct_answers(array &$tasks): void { - foreach ($challenges as &$challenge) { - $challenge["correct_answer"] = -1; + foreach ($tasks as &$task) { + $task["correct_answer"] = null; } } @@ -263,7 +273,7 @@ function save_player_answer(ReqHandler &$rh, array $params): string { $test = access_test_data($params["testid"]); if ($test !== null) { - $test->saveAnswer($params["challenge_index"], $params["answer_index"]); + $test->saveAnswer($params["task_index"], $params["answer"]); return "OK"; } else { return "FAIL"; @@ -310,7 +320,7 @@ function get_image(ReqHandler &$rh, array $params): string } $rh->add("get_test", ["testid"], PRIVILEGE_PLAYER, "get_player_test", RESP_JSON, "Get player's test by ID."); -$rh->add("save_answer", ["testid", "challenge_index", "answer_index"], PRIVILEGE_PLAYER, "save_player_answer", RESP_PLAIN, "Store player's answer."); +$rh->add("save_answer", ["testid", "task_index", "answer"], PRIVILEGE_PLAYER, "save_player_answer", RESP_PLAIN, "Store player's answer."); $rh->add("submit_test", ["testid"], PRIVILEGE_PLAYER, "submit_test", RESP_PLAIN, "Finish player's test."); $rh->add("get_image", ["gameid", "img_url"], PRIVILEGE_PLAYER, "get_image", RESP_NONE, "Get image per game."); @@ -378,39 +388,59 @@ function create_update_game(ReqHandler &$rh, array $params): array // update game file if supplied if (isset($_FILES["game_file"])) { - // decide weather it's a package or a plain table + // decide whether it's a package or a plain table $file = $_FILES["game_file"]; - $challenge_import_status = []; + $task_import_status = ["n" => 0, "encoding" => "(N/A)"]; + + // fetch actual and temporary file name + $file_name = $file["name"]; + $file_path = $file["tmp_name"]; // determine MIME type - $file_type = strtolower(pathinfo($file["name"], PATHINFO_EXTENSION)); + $process_once_more = false; + do { + // don't process one more time by default + $process_once_more = false; - if ($file_type === "zip") { // a package was uploaded - $zip = new ZipArchive; - if ($zip->open($file["tmp_name"])) { + // get file type + $file_type = strtolower(pathinfo($file_name, PATHINFO_EXTENSION)); - $game_dir = $game->getGameDir(); // get game directory - //$game_files = glob($game_dir); // get list of existing game files - // remove former files recursively - $dir_iter = new RecursiveDirectoryIterator($game_dir, FilesystemIterator::SKIP_DOTS); - foreach ($dir_iter as $dir_item) { - $item_path = $dir_item->getPathname(); - $dir_item->isDir() ? rmdir($item_path) : unlink($item_path); - } - - // extract package contents to the game directory - $zip->extractTo($game_dir . DIRECTORY_SEPARATOR); - - // search for the CSV table file - $csv_files = glob($game_dir . DIRECTORY_SEPARATOR . "*.csv") ?? []; - if (count($csv_files) > 0) { - $challenge_import_status = $game->importChallengesFromCSV($csv_files[0]); + // extract the archive if a ZIP package was uploaded + if ($file_type === "zip") { + $zip = new ZipArchive; + if ($zip->open($file_path)) { + // get game directory + $game_dir = $game->getGameDir(); + //$game_files = glob($game_dir); // get list of existing game files + + // remove former files recursively + $dir_iter = new RecursiveDirectoryIterator($game_dir, FilesystemIterator::SKIP_DOTS); + foreach ($dir_iter as $dir_item) { + $item_path = $dir_item->getPathname(); + $dir_item->isDir() ? rmdir($item_path) : unlink($item_path); + } + + // extract package contents to the game directory + $zip->extractTo($game_dir . DIRECTORY_SEPARATOR); + + // search for the table file + $table_files = glob($game_dir . DIRECTORY_SEPARATOR . "*.{csv,xls,xlsx,ods}", \GLOB_BRACE); + if (count($table_files) > 0) { + $file_name = $table_files[0]; + $file_path = $file_name; + $process_once_more = true; + } } + } else if ($file_type === "csv") { // a plain table was uploaded + $task_import_status = $game->importTasksFromCSV($file_path); + } else if (in_array($file_type, ["xls", "xlsx", "ods"])) { + $spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($file_path, readers: [ucfirst($file_type)]); + $sheet = $spreadsheet->getSheet(0); + $table = $sheet->toArray(); + $task_import_status = $game->importTasksFromTable($table); } - } else if ($file_type === "csv") { // a plain table was uploaded - $challenge_import_status = $game->importChallengesFromCSV($file["tmp_name"]); - } - $result = $challenge_import_status; + } while ($process_once_more); + $result = $task_import_status; } } } @@ -430,7 +460,7 @@ function get_all_game_headers(ReqHandler &$rh, array $params): array return $a; } -function get_challenges(ReqHandler &$rh, array $params): string +function get_tasks(ReqHandler &$rh, array $params): string { global $user; global $gameMgr; @@ -473,7 +503,7 @@ function export_game_file_csv(ReqHandler &$rh, array $params): string $f = tmpfile(); header("Content-Type: text/csv"); header("Content-Disposition: attachment; filename=\"challenges_$gameid.csv\"\r\n"); - $game->exportChallengesToCSV($f); + $game->exportTasksToCSV($f); fseek($f, 0); fpassthru($f); } @@ -653,7 +683,7 @@ function delete_tests(ReqHandler &$rh, array $params): string $rh->add(["create_game", "update_game"], ["data"], PRIVILEGE_CREATOR, "create_update_game", RESP_JSON, "Create or update game."); $rh->add("get_all_game_headers", [], PRIVILEGE_CREATOR, "get_all_game_headers", RESP_JSON, "Get all game headers."); -$rh->add("get_challenges", [], PRIVILEGE_CREATOR, "get_challenges", RESP_PLAIN, "Get game challenges."); +$rh->add("get_tasks", [], PRIVILEGE_CREATOR, "get_tasks", RESP_PLAIN, "Get game tasks."); $rh->add("delete_games", ["ids"], PRIVILEGE_CREATOR, "delete_games", RESP_PLAIN, "Delete games."); $rh->add("export_game_file_csv", ["gameid"], PRIVILEGE_CREATOR, "export_game_file_csv", RESP_NONE, "Export game CSV file."); $rh->add("get_results_by_gameid", ["gameid"], PRIVILEGE_CREATOR, "get_results_by_gameid", RESP_JSON, "Get game results."); @@ -864,6 +894,16 @@ function import_users_from_csv(ReqHandler &$rh, array $params): string return "OK"; } +function execute_cli_command(ReqHandler &$rh, array $params): string +{ + $args = $params["cmd"]; + $cmdline = "php cli_actions.php $args"; + $resp = "=> " . $cmdline . "\n\n"; + $resp .= shell_exec($cmdline); + //$resp = shell_exec("php -v"); + return $resp ?? "(null output)"; +} + $rh->add("create_group", ["groupname", "description"], PRIVILEGE_QUIZMASTER, "create_update_group", RESP_PLAIN, "Create group."); $rh->add("update_group", ["groupname", "description", "owner", "editors", "id"], PRIVILEGE_QUIZMASTER, "create_update_group", RESP_PLAIN, "Update group."); $rh->add("delete_groups", ["ids"], PRIVILEGE_QUIZMASTER, "delete_groups", RESP_PLAIN, "Delete group."); @@ -878,6 +918,8 @@ $rh->add("get_user_groups", ["nickname"], PRIVILEGE_QUIZMASTER, "get_user_groups $rh->add("get_game_groups", ["gameid"], PRIVILEGE_QUIZMASTER, "get_game_groups", RESP_JSON, "Get game's groups."); $rh->add("import_users_from_csv", [], PRIVILEGE_QUIZMASTER, "import_users_from_csv", RESP_JSON, "Get all users."); +$rh->add("execute_cli_command", ["cmd"], PRIVILEGE_QUIZMASTER, "execute_cli_command", RESP_PLAIN, "Run cli command."); + //function test(ReqHandler &$rh, array $params): string //{ // $usrmgr = new UserMgr(); diff --git a/js/default_frame.js b/js/default_frame.js index 8fecf62..54adaef 100644 --- a/js/default_frame.js +++ b/js/default_frame.js @@ -56,7 +56,11 @@ function start_or_continue_test(gameid) { request(req).then(resp => { if (resp.length > 0) // response is non-zero { - open_test(resp, gameid); + if (Number(resp) !== -1) { + open_test(resp, gameid); + } else { + alert("A teszt nem indítható el!"); + } } }); } diff --git a/js/gamemgr.js b/js/gamemgr.js index 037acde..9ffd24b 100644 --- a/js/gamemgr.js +++ b/js/gamemgr.js @@ -59,10 +59,10 @@ function create_edit_game(game = null) { let ownerF = document.getElementById("game_owner"); let contributorsF = document.getElementById("game_contributors"); let gameFileF = document.getElementById("game_file"); - let download_challenges_btn = document.getElementById("download_challenges_btn"); + let download_tasks_btn = document.getElementById("download_tasks_btn"); let show_game_file_upload_btn = document.getElementById("show_game_file_upload"); let cancel_game_file_upload_btn = document.getElementById("cancel_game_file_upload"); - let edit_challenges_btn = document.getElementById("edit_challenges_btn"); + let edit_tasks_btn = document.getElementById("edit_tasks_btn"); let groupF = document.getElementById("game_groups"); let time_limitedChk = document.getElementById("time_limited"); let time_limitF = document.getElementById("time_limit"); @@ -136,14 +136,14 @@ function create_edit_game(game = null) { let game_file_present = updating && game["game_file_present"]; if (game_file_present) { - show(download_challenges_btn); - show(edit_challenges_btn); - edit_challenges_btn.onclick = () => { - edit_challenges(game); + show(download_tasks_btn); + show(edit_tasks_btn); + edit_tasks_btn.onclick = () => { + edit_tasks(game); }; } else { - hide(download_challenges_btn); - hide(edit_challenges_btn); + hide(download_tasks_btn); + hide(edit_tasks_btn); } show_hide_gamefile_upload(false); @@ -206,7 +206,7 @@ function show_hide_gamefile_upload(en) { } } -function download_challenges() { +function download_tasks() { let action = "export_game_file_csv"; let gameid = EDITED_GAME["_id"]; window.open(`interface.php?action=${action}&gameid=${gameid}`, "_blank"); @@ -251,8 +251,8 @@ function handle_time_limit_chkbox() { time_limitF.disabled = !time_limitedChk.checked; } -function edit_challenges(game) { - let req = {action: "get_challenges", gameid: game["_id"]}; +function edit_tasks(game) { + let req = {action: "get_tasks", gameid: game["_id"]}; request(req).then(resp => { console.log(JSON.parse(resp)); }); diff --git a/js/result_analyzer.js b/js/result_analyzer.js index 17519fc..1a3e9f6 100644 --- a/js/result_analyzer.js +++ b/js/result_analyzer.js @@ -158,35 +158,35 @@ function generate_report() { report_display.innerHTML = ""; let ch_n = 0; - stats.forEach((challenge) => { - let challenge_box = document.createElement("section"); - challenge_box.classList.add("challenge"); - challenge_box.style.width = "100%"; + stats.forEach((task) => { + let task_box = document.createElement("section"); + task_box.classList.add("task"); + task_box.style.width = "100%"; let seq_num = document.createElement("section"); seq_num.classList.add("seq-num"); seq_num.innerText = ++ch_n; - challenge_box.append(seq_num); + task_box.append(seq_num); - let img_url = challenge["image_url"]; + let img_url = task["image_url"]; if (img_url !== "") { let fig = document.createElement("img"); - fig.src = `interface.php?action=get_image&gameid=${GAMEID}&img_url=${challenge["image_url"]}`; + fig.src = `interface.php?action=get_image&gameid=${GAMEID}&img_url=${task["image_url"]}`; fig.classList.add("question-image"); - challenge_box.append(fig); + task_box.append(fig); } let question = document.createElement("span"); question.classList.add("question"); - question.innerHTML = preprocess_inserts(challenge["question"]); + question.innerHTML = preprocess_inserts(task["question"]); let answer_container = document.createElement("section"); answer_container.classList.add("answer-container"); - challenge_box.append(question, answer_container); + task_box.append(question, answer_container); - let n = challenge["answer_count"]; + let n = task["answer_count"]; for (let i = 0; i < n; i++) { - let answer = challenge["answers"][i]; - let correct_answer = answer === challenge["correct_answer"]; + let answer = task["answers"][i]; + let correct_answer = answer === task["correct_answer"]; let answer_section = document.createElement("section"); answer_section.classList.add("answer"); @@ -196,7 +196,7 @@ function generate_report() { let progress_bar_indicator = document.createElement("section"); progress_bar_indicator.classList.add("pb-indicator") - let percentage = challenge["answer_ratio"][i] * 100; + let percentage = task["answer_ratio"][i] * 100; progress_bar_indicator.style.width = `${percentage}%`; progress_bar_indicator.innerText = Math.round(percentage * 100.0) / 100.0 + "%"; progress_bar_indicator.setAttribute("correct", correct_answer ? "true" : "false"); @@ -213,7 +213,7 @@ function generate_report() { answer_container.append(answer_section); } - report_display.append(challenge_box); + report_display.append(task_box); }); statsTab.MathJax.typeset(); diff --git a/js/tasks.js b/js/tasks.js new file mode 100644 index 0000000..5686a25 --- /dev/null +++ b/js/tasks.js @@ -0,0 +1,465 @@ +class Task extends HTMLElement { + static sequence_number = 0; + constructor(type) { + super(); + + this.task_type = type; + this.sequence_number = Task.sequence_number++; + this.concluded = false; + this.view_only = false; + this.upload_answer_cb = null; + this.player_answer = null; + + this.shadow = this.attachShadow({mode: "open"}); + this.createStyle(); + this.createElements(); + } + + createStyle() { + this.css = document.createElement("style"); + this.css.innerHTML = ` + span.question { + font-size: 1.3em; + font-weight: bold; + color: #176767; + } + section.task { + display: block; + position: relative; + margin: 0 auto; + width: 40em; + padding: 1em; + background-color: #d3e5e5; + margin-bottom: 0.5em; + border-radius: 0.3em; + } + section.seq-num { + display: block; + position: absolute; + right: 0; + padding: 0.5em; + bottom: 0; + background-color: #176767; + color: whitesmoke; + border-bottom-right-radius: 0.3em; + border-top-left-radius: 0.3em; + width: 2em; + } + section.answer-container { + /* (empty) */ + } + code { + font-family: 'Monaco', monospace; + } + + @media only screen and (max-width: 800px) { + section.task { + width: calc(100vw - 3em); + } + section.answer-container { + margin-bottom: 1.5em; + } + } + `; + this.shadow.append(this.css); + } + + createElements() { + let task_box = document.createElement("section"); + task_box.classList.add("task"); + let question_span = document.createElement("span"); + question_span.classList.add("question"); + let answer_container = document.createElement("section"); + answer_container.classList.add("answer-container"); + let seq_num_section = document.createElement("section"); + seq_num_section.classList.add("seq-num"); + seq_num_section.innerText = `${this.sequence_number+1}.`; + + task_box.append(question_span); + task_box.append(answer_container); + task_box.append(seq_num_section); + + this.shadow.append(task_box); + + this.task_box = task_box; + this.question_span = question_span; + this.answer_container = answer_container; + this.seq_num_section = seq_num_section; + } + + connectedCallback() {} + + disconnectedCallback() {} + + get type() { + return this.task_type; + } + + set type(type) { + this.task_type = type; + } + + get sequenceNumber() { + return this.sequence_number; + } + + setQuestion(question) { + this.question_span.innerHTML = preprocess_inserts(question); + } + + get isConcluded() { + return this.concluded; + } + + set isConcluded(concluded) { + this.concluded = concluded; + } + + get isViewOnly() { + return this.view_only; + } + + set isViewOnly(viewOnly) { + this.view_only = viewOnly; + } + + get isCorrect() { + return false; + } + + set playerAnswer(player_answer) { + this.player_answer = player_answer; + } + + get playerAnswer() { + return this.player_answer; + } + + set uploadAnswerCb(cb) { + this.upload_answer_cb = cb; + } + + uploadAnswer() { + if (this.upload_answer_cb !== null) { + this.upload_answer_cb(this.sequence_number, this.playerAnswer); + } + } + + fromArray(a) { + this.setQuestion(a["question"]); + this.playerAnswer = a["player_answer"]; + } +} + +class PicturedTask extends Task { + constructor(type) { + super(type); + } + + createStyle() { + super.createStyle(); + + this.css.innerHTML += ` + img.question-image { + display: none; + position: relative; + margin: 1em auto; + border-radius: 0.3em; + max-width: 100%; + } + `; + } + + createElements() { + super.createElements(); + + this.img = document.createElement("img"); + this.img.classList.add("question-image"); + this.img.src = ""; + + this.task_box.insertBefore(this.img, this.question_span); + } + + set imgUrl(url) { + url = url.trim(); + this.img.src = url.trim(); + this.img.style.display = (url !== "") ? "block" : "none"; + } + + get imgUrl() { + return this.img.src; + } +} + +class SingleChoiceTask extends PicturedTask { + constructor() { + super("singlechoice"); + + this.answers = [] + this.correct_answer = -1; + this.player_answer = -1; + } + + createStyle() { + super.createStyle(); + + this.css.innerHTML += ` + section.answer { + margin: 0.3em 0.8em; + display: block; + } + section.answer label { + margin-left: 0.5em; + padding: 0.3em 0.5em; + border-radius: 0.3em; + + display: inline-block; + max-width: 85%; + vertical-align: middle; + } + section.answer label.correct-answer { + border: 2px solid #176767 !important; + background-color: #176767; + color: whitesmoke; + /*padding: 0.1em;*/ + } + section.answer input[type="radio"]:checked+label:not(.correct-answer) { + background-color: #176767; + color: whitesmoke; + } + section.bad-answer { + background-color: #e5d8d3; + } + section.bad-answer section.answer input[type="radio"]:checked+label:not(.correct-answer) { + background-color: #aa8a7d; + } + .MathJax { + display: block; + margin: 0.5em auto; + font-size: 120%; + } + @media only screen and (max-width: 800px) { + section.answer label { + max-width: calc(100% - 4em); + } + } + `; + } + + createElements() { + super.createElements(); + } + + // -------- + + setAnswers(answers) { + this.answers = answers; + + this.answers.forEach((answer, i) => { + let answer_section = document.createElement("section"); + answer_section.classList.add("answer"); + let answer_radio = document.createElement("input"); + answer_radio.type = "radio"; + answer_radio.id = `${this.sequenceNumber}_${i}`; + answer_radio.name = `task_${this.sequenceNumber}`; + answer_radio.disabled = this.isConcluded || this.isViewOnly; + let answer_N_snapshot = i; + answer_radio.addEventListener("input", () => { + this.playerAnswer = answer_N_snapshot; + this.uploadAnswer(); + }); + + let answer_text = document.createElement("label"); + answer_text.innerHTML = preprocess_inserts(answer); + answer_text.htmlFor = answer_radio.id; + if (this.isConcluded && (this.correctAnswer === i)) { + answer_text.classList.add("correct-answer") + + if (this.playerAnswer !== this.correctAnswer) { + this.task_box.classList.add("bad-answer"); + } + } + + if (this.playerAnswer === i) { + answer_radio.checked = true; + } + + answer_section.append(answer_radio, answer_text); + this.answer_container.append(answer_section); + }); + + MathJax.typeset([ this.task_box ]); + } + + set correctAnswer(correct_answer) { + this.correct_answer = correct_answer; + } + + get correctAnswer() { + return this.correct_answer; + } + + get isCorrect() { + return this.player_answer === this.correct_answer; + } + + fromArray(a) { + super.fromArray(a); + + this.correctAnswer = a["correct_answer"]; + this.setAnswers(a["answers"]); + } + +} + +class OpenEndedTask extends PicturedTask { + constructor() { + super("openended"); + } + + createStyle() { + super.createStyle(); + + this.css.innerHTML += ` + input[type="text"] { + font-family: 'Monaco', monospaced; + border-width: 0 0 2.2pt 0; + background-color: transparent; + width: calc(100% - 4em); + margin: 1em 0; + border-bottom-color: #176767; + font-size: 110%; + } + input[type="text"]:hover { + border-bottom-color: #408d8d; + } + ` + } + + createElements() { + super.createElements(); + + let answer_tf = document.createElement("input"); + answer_tf.type = "text"; + answer_tf.placeholder = "(válasz)"; + + answer_tf.onblur = () => { + this.uploadAnswer(); + } + answer_tf.oninput = () => { + this.player_answer = answer_tf.value; + }; + + this.answer_container.append(answer_tf); + + this.answer_tf = answer_tf; + } + + fromArray(a) { + super.fromArray(a); + } + + set playerAnswer(player_answer) { + super.playerAnswer = player_answer; + this.answer_tf.value = player_answer; + } + + get playerAnswer() { + return this.player_answer; + } + + updateAnswerFieldState() { + this.answer_tf.disabled = this.isViewOnly || this.isConcluded; + } + + set isConcluded(concluded) { + super.isConcluded = concluded; + this.updateAnswerFieldState(); + } + + get isConcluded() { + return super.isConcluded; + } + + set isViewOnly(is_view_only) { + super.isViewOnly = is_view_only; + } + + get isViewOnly() { + return super.isViewOnly; + } +} + +class NumberConversionTask extends OpenEndedTask { + constructor() { + super(); + this.type = "numberconversion"; + } + + createStyle() { + super.createStyle(); + + this.css.innerHTML += ` + input[type="text"] { + min-width: 5em; + width: unset; + } + section#src, section#dst { + position: relative; + display: inline-block; + font-family: 'Monaco', monospace; + color: #176767; + } + section#src { + margin-right: 1ch; + } + sub { + position: relative; + top: 0.8em; + } + `; + } + + createElements() { + super.createElements(); + + let src_sec = document.createElement("section"); + src_sec.id = "src"; + let dst_sec = document.createElement("section"); + dst_sec.id = "dst"; + + this.answer_container.insertBefore(src_sec, this.answer_tf); + this.answer_container.append(dst_sec); + + this.src_sec = src_sec; + this.dst_sec = dst_sec; + + this.answer_tf.addEventListener("input", () => { + this.updateAnswerFieldLength(); + }) + } + + fromArray(a) { + super.fromArray(a); + + const regex = /([0-9]+)([suc]):([0-9]+)->([0-9]+)([suc]):([0-9]+)/g; + let parts = [ ...a["instruction"].matchAll(regex) ][0]; + + let src_exp = `${a["source"]}(${parts[1]}) =`; + let dst_exp = `(${parts[4]}) (${parts[6]} digiten)`; + + this.src_sec.innerHTML = src_exp; + this.dst_sec.innerHTML = dst_exp; + + this.updateAnswerFieldLength(); + } + + updateAnswerFieldLength() { + this.answer_tf.style.width = this.answer_tf.value.length + "ch"; + } +} + +customElements.define('singlechoice-task', SingleChoiceTask); +customElements.define('openended-task', OpenEndedTask); +customElements.define('numberconversion-task', NumberConversionTask); + diff --git a/js/terminal.js b/js/terminal.js new file mode 100644 index 0000000..b3cd97a --- /dev/null +++ b/js/terminal.js @@ -0,0 +1,17 @@ +function submit_command() { + let terminal_input = document.getElementById('terminal_input'); + let terminal_output = document.getElementById('terminal_output'); + + let cmd = terminal_input.value.trim(); + terminal_input.disabled = true; + if (cmd !== "") { + terminal_output.value += ">> " + cmd + "\n"; + let req = {"action": "execute_cli_command", "cmd" : cmd}; + request(req).then((resp) => { + terminal_output.value += resp + "\n\n"; + terminal_output.scrollTo(0, terminal_output.scrollHeight); + terminal_input.value = ""; + terminal_input.disabled = false; + }); + } +} \ No newline at end of file diff --git a/js/testground.js b/js/testground.js index dd483f6..190abd1 100644 --- a/js/testground.js +++ b/js/testground.js @@ -20,9 +20,9 @@ function populate_infobox(test_data, view_only) { if (test_concluded) { let summary = test_data["summary"]; let correct_answer_n = summary["correct_answer_n"]; - let challenge_n = summary["challenge_n"]; - let r = Math.ceil((correct_answer_n / challenge_n) * 100); - percentageS.innerHTML = `${r}% (${correct_answer_n}/${challenge_n})`; + let task_n = summary["challenge_n"]; + let r = Math.ceil((correct_answer_n / task_n) * 100); + percentageS.innerHTML = `${r}% (${correct_answer_n}/${task_n})`; let start_time = unix_time_to_human_readable(test_data["start_time"]); let end_time = unix_time_to_human_readable(test_data["end_time"]); @@ -70,87 +70,22 @@ function populate_infobox(test_data, view_only) { } } -function assemble_answer_radio_id(challenge_N, answer_N) { - return challenge_N + "_" + answer_N; -} - -function mark_answers(challenges, view_only = false) { - for (let i = 0; i < challenges.length; i++) { - let marked_answerR = document.getElementById(assemble_answer_radio_id(i, challenges[i]["player_answer"])); - if (marked_answerR !== null) { - marked_answerR.checked = true; - } - } -} - -function populate_challenges(challenges, concluded, view_only = false, gameid) { +function populate_tasks(tasks, concluded, view_only = false, gameid) { let test_display = document.getElementById("test_display"); test_display.innerHTML = ""; - let challenge_N = 0; - challenges.forEach((challenge) => { - let challenge_N_snapshot = challenge_N; - let challenge_box = document.createElement("section"); - challenge_box.classList.add("challenge"); - let question = document.createElement("span"); - question.classList.add("question"); - question.innerHTML = preprocess_inserts(challenge["question"]); - let answer_container = document.createElement("section"); - answer_container.classList.add("answer-container"); - challenge_box.append(question, answer_container); + tasks.forEach((task) => { + let task_element = document.createElement(`${task["type"]}-task`); + task_element.uploadAnswerCb = save_answer; - if (challenge["image_url"] !== "") { - let qimg = document.createElement("img"); - qimg.src = `interface.php?action=get_image&gameid=${gameid}&img_url=${challenge["image_url"]}`; - qimg.classList.add("question-image") - challenge_box.insertBefore(qimg, answer_container); + if (task["image_url"] !== "") { + task_element.imgUrl = `interface.php?action=get_image&gameid=${gameid}&img_url=${task["image_url"]}` } - - let seq_num_section = document.createElement("section"); - seq_num_section.innerText = String(challenge_N + 1) + "." - seq_num_section.classList.add("seq-num"); - challenge_box.append(seq_num_section); - - let answer_N = 0; - let player_answer = challenge["player_answer"]; - player_answer = (player_answer !== "") ? Number(player_answer) : -1; - challenge["answers"].forEach((answer) => { - let answer_section = document.createElement("section"); - answer_section.classList.add("answer"); - let answer_radio = document.createElement("input"); - answer_radio.type = "radio"; - answer_radio.id = `${challenge_N}_${answer_N}`; - answer_radio.name = `challenge_${challenge_N}`; - answer_radio.disabled = concluded || view_only; - let answer_N_snapshot = answer_N; - answer_radio.addEventListener("input", () => { - save_answer(challenge_N_snapshot, answer_N_snapshot); - }); - - let answer_text = document.createElement("label"); - answer_text.innerHTML = preprocess_inserts(answer); - answer_text.setAttribute("for", answer_radio.id); - if (concluded && (challenge["correct_answer"] === answer_N)) { - answer_text.classList.add("correct-answer") - - if (player_answer !== challenge["correct_answer"]) { - challenge_box.classList.add("bad-answer"); - } - } - - answer_section.append(answer_radio, answer_text); - answer_container.appendChild(answer_section); - - answer_N++; - }); - challenge_N++; - - test_display.appendChild(challenge_box); + task_element.isConcluded = concluded; + task_element.isViewOnly = view_only; + task_element.fromArray(task); + test_display.appendChild(task_element); }); - - mark_answers(challenges, view_only); - - MathJax.typeset(); } function populate_all(test_id, gameid, view_only) { @@ -162,22 +97,33 @@ function populate_all(test_id, gameid, view_only) { request(req).then(resp => { TEST_DATA = JSON.parse(resp); let concluded = TEST_DATA["state"] === "concluded"; - populate_challenges(TEST_DATA["challenges"], concluded, view_only, gameid); + populate_tasks(TEST_DATA["challenges"], concluded, view_only, gameid); populate_infobox(TEST_DATA, view_only); }); } -function save_answer(chidx, aidx) { +function save_answer(tidx, ans) { let req = { action: "save_answer", testid: TEST_DATA["_id"], - challenge_index: chidx, - answer_index: aidx, + task_index: tidx, + answer: ans, }; request(req); } +function save_all_answers() { + let tasks = document.getElementById("test_display").children; + for (let i = 0; i < tasks.length; i++) { + tasks[i].uploadAnswer(); + } +} + function submit_test() { + // first, save all answers + save_all_answers(); + + // then signal test submission let req = { action: "submit_test", testid: TEST_DATA["_id"] @@ -185,4 +131,91 @@ function submit_test() { request(req).then(resp => { populate_all(TEST_DATA["_id"], TEST_DATA["gameid"], false); }); -} \ No newline at end of file +} + +// --------- + +// function populate_tasks(tasks, concluded, view_only = false, gameid) { +// let test_display = document.getElementById("test_display"); +// test_display.innerHTML = ""; +// +// let task_N = 0; +// tasks.forEach((task) => { +// let task_N_snapshot = task_N; +// let task_box = document.createElement("section"); +// task_box.classList.add("task"); +// let question = document.createElement("span"); +// question.classList.add("question"); +// question.innerHTML = preprocess_inserts(task["question"]); +// let answer_container = document.createElement("section"); +// answer_container.classList.add("answer-container"); +// task_box.append(question, answer_container); +// +// if (task["image_url"] !== "") { +// let qimg = document.createElement("img"); +// qimg.src = `interface.php?action=get_image&gameid=${gameid}&img_url=${task["image_url"]}`; +// qimg.classList.add("question-image") +// task_box.insertBefore(qimg, answer_container); +// } +// +// let seq_num_section = document.createElement("section"); +// seq_num_section.innerText = String(task_N + 1) + "." +// seq_num_section.classList.add("seq-num"); +// task_box.append(seq_num_section); +// +// let answer_N = 0; +// let player_answer = task["player_answer"]; +// player_answer = (player_answer !== "") ? Number(player_answer) : -1; +// task["answers"].forEach((answer) => { +// let answer_section = document.createElement("section"); +// answer_section.classList.add("answer"); +// let answer_radio = document.createElement("input"); +// answer_radio.type = "radio"; +// answer_radio.id = `${task_N}_${answer_N}`; +// answer_radio.name = `task_${task_N}`; +// answer_radio.disabled = concluded || view_only; +// let answer_N_snapshot = answer_N; +// answer_radio.addEventListener("input", () => { +// save_answer(task_N_snapshot, answer_N_snapshot); +// }); +// +// let answer_text = document.createElement("label"); +// answer_text.innerHTML = preprocess_inserts(answer); +// answer_text.setAttribute("for", answer_radio.id); +// if (concluded && (task["correct_answer"] === answer_N)) { +// answer_text.classList.add("correct-answer") +// +// if (player_answer !== task["correct_answer"]) { +// task_box.classList.add("bad-answer"); +// } +// } +// +// answer_section.append(answer_radio, answer_text); +// answer_container.appendChild(answer_section); +// +// answer_N++; +// }); +// task_N++; +// +// test_display.appendChild(task_box); +// }); +// +// mark_answers(tasks, view_only); +// +// MathJax.typeset(); +// } +// +// +// function assemble_answer_radio_id(task_N, answer_N) { +// return task_N + "_" + answer_N; +// } +// +// +// function mark_answers(tasks, view_only = false) { +// for (let i = 0; i < tasks.length; i++) { +// let marked_answerR = document.getElementById(assemble_answer_radio_id(i, tasks[i]["player_answer"])); +// if (marked_answerR !== null) { +// marked_answerR.checked = true; +// } +// } +// } diff --git a/js/usermgr.js b/js/usermgr.js index f78ded3..ab9c3e7 100644 --- a/js/usermgr.js +++ b/js/usermgr.js @@ -83,7 +83,7 @@ function create_edit_user(user = null) { nicknameF.value = user["nickname"]; nicknameF.readOnly = true; realnameF.value = user["realname"]; - passwordF.type = "password"; + passwordF.task_type = "password"; passwordF.value = ""; passwordF.readOnly = false; groupsF.value = ""; @@ -97,7 +97,7 @@ function create_edit_user(user = null) { nicknameF.value = ""; nicknameF.readOnly = false; realnameF.value = ""; - passwordF.type = "text"; + passwordF.task_type = "text"; passwordF.value = generateRandomString(); passwordF.readOnly = true; groupsF.value = ""; diff --git a/main.php b/main.php index c99888e..5860fb9 100644 --- a/main.php +++ b/main.php @@ -29,8 +29,15 @@ $privilege = $user_data["privilege"]; + + + + + + +
@@ -52,16 +59,37 @@ $privilege = $user_data["privilege"];
- + - - + + +
+ +
+
+
+
+ + +
+
+
diff --git a/report.html b/report.html index 141615b..ab0d275 100644 --- a/report.html +++ b/report.html @@ -10,6 +10,7 @@ + diff --git a/style/quizmaster_area.css b/style/quizmaster_area.css index 1266b95..478ed3a 100644 --- a/style/quizmaster_area.css +++ b/style/quizmaster_area.css @@ -132,4 +132,10 @@ span.answer[correct=true] { color: whitesmoke; background-color: #176767; border-radius: 0.3em; +} + +.terminal-style { + font-family: 'Monaco', monospace; + width: 40em; + font-size: 12pt; } \ No newline at end of file diff --git a/style/report.css b/style/report.css new file mode 100644 index 0000000..b2ac9d0 --- /dev/null +++ b/style/report.css @@ -0,0 +1,74 @@ +/* FIXME: ezek mind át lettek téve a task custom-elementbe */ + +section.task { + display: block; + position: relative; + margin: 0 auto; + width: 40em; + /*border: 1px solid black;*/ + padding: 1em; + background-color: #d3e5e5; + margin-bottom: 0.5em; + border-radius: 0.3em; +} + +span.question { + font-size: 1.3em; + font-weight: bold; + color: #176767; +} + +section.answer-container { + +} + +section.answer { + margin: 0.3em 0.8em; + display: block; +} + +section.answer label { + margin-left: 0.5em; + padding: 0.3em 0.5em; + border-radius: 0.3em; + + display: inline-block; + max-width: 85%; + vertical-align: middle; +} + +section.answer label.correct-answer { + border: 2px solid #176767 !important; + background-color: #176767; + color: whitesmoke; +} + +section.answer input[type="radio"]:checked+label:not(.correct-answer) { + background-color: #176767; + color: whitesmoke; +} + +code { + font-family: 'Monaco', monospace; +} + +img.question-image { + display: block; + position: relative; + margin: 1em auto; + border-radius: 0.3em; + max-width: 100%; +} + +section.seq-num { + display: block; + position: absolute; + right: 0; + padding: 0.5em; + bottom: 0; + background-color: #176767; + color: whitesmoke; + border-bottom-right-radius: 0.3em; + border-top-left-radius: 0.3em; + width: 2em; +} \ No newline at end of file diff --git a/style/spreadquiz.css b/style/spreadquiz.css index d058141..84b14c5 100644 --- a/style/spreadquiz.css +++ b/style/spreadquiz.css @@ -229,55 +229,6 @@ section#test_area { right: 0; } -section.challenge { - display: block; - position: relative; - margin: 0 auto; - width: 40em; - /*border: 1px solid black;*/ - padding: 1em; - background-color: #d3e5e5; - margin-bottom: 0.5em; - border-radius: 0.3em; -} - -span.question { - font-size: 1.3em; - font-weight: bold; - color: #176767; -} - -section.answer-container { - -} - -section.answer { - margin: 0.3em 0.8em; - display: block; -} - -section.answer label { - margin-left: 0.5em; - padding: 0.3em 0.5em; - border-radius: 0.3em; - - display: inline-block; - max-width: 85%; - vertical-align: middle; -} - -section.answer label.correct-answer { - border: 2px solid #176767 !important; - background-color: #176767; - color: whitesmoke; - /*padding: 0.1em;*/ -} - -section.answer input[type="radio"]:checked+label:not(.correct-answer) { - background-color: #176767; - color: whitesmoke; -} - section#infobox { display: block; position: fixed; @@ -368,39 +319,6 @@ section.test-summary-record { bottom: 0; } -code { - font-family: 'Monaco', monospace; -} - -img.question-image { - display: block; - position: relative; - margin: 1em auto; - border-radius: 0.3em; - max-width: 100%; -} - -section.seq-num { - display: block; - position: absolute; - right: 0; - padding: 0.5em; - bottom: 0; - background-color: #176767; - color: whitesmoke; - border-bottom-right-radius: 0.3em; - border-top-left-radius: 0.3em; - width: 2em; -} - -section.bad-answer { - background-color: #e5d8d3; -} - -section.bad-answer section.answer input[type="radio"]:checked+label:not(.correct-answer) { - background-color: #aa8a7d; -} - section#further-info { font-size: 0.8em; padding: 0.4em 0; diff --git a/style/spreadquiz_mobile.css b/style/spreadquiz_mobile.css index 8f1e320..8705f6f 100644 --- a/style/spreadquiz_mobile.css +++ b/style/spreadquiz_mobile.css @@ -34,13 +34,15 @@ height: 12em; } - section.challenge { - width: calc(100vw - 3em); - } + /*!* FIXME: áttéve *!*/ + /*section.task {*/ + /* width: calc(100vw - 3em);*/ + /*}*/ - section.answer label { - max-width: 80%; - } + /*!* FIXME: áttéve *!*/ + /*section.answer label {*/ + /* max-width: 80%;*/ + /*}*/ section#infobox { top: unset; diff --git a/testground.php b/testground.php index e3540d3..581b8e1 100644 --- a/testground.php +++ b/testground.php @@ -30,9 +30,12 @@ if ($testid === "") { + + +
@@ -66,6 +69,11 @@ if ($testid === "") {
\ No newline at end of file