diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0348fe9..cac6cb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ permissions: contents: write env: - UPGRADE_BASE_TAG: v2.1.4 + UPGRADE_BASE_TAG: v2.2.0 jobs: verify-release: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c654f2..49868bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,24 @@ The format is intentionally lightweight and release-friendly so entries can be r ## Unreleased +## [v2.2.1] - 2026-07-30 + +### Added + +- Quiz owners can export question text, answer choices, and correct-answer positions as a UTF-8 CSV and import the same format directly into another quiz. + +### Changed + +- Question CSV imports now use streaming RFC-compatible parsing, support up to the teacher's remaining quota with a 500-question safety cap, and provide localized validation feedback. + +### Fixed + +- Question pages now keep the background veil visible from the first paint and use a shorter card entrance, preventing a flash of the unfiltered background between questions. + +### Security + +- Updated the locked frontend build dependencies within their compatible release ranges, resolving the current Axios, Vite, Rollup, PostCSS, form-data, Lodash, and related npm advisories. + ## [v2.2.0] - 2026-07-28 ### Added diff --git a/app/Http/Controllers/QuestionController.php b/app/Http/Controllers/QuestionController.php index 6811524..416eadd 100644 --- a/app/Http/Controllers/QuestionController.php +++ b/app/Http/Controllers/QuestionController.php @@ -20,11 +20,18 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use Illuminate\View\View; +use Symfony\Component\HttpFoundation\StreamedResponse; class QuestionController extends Controller { use AuthorizesQuizOwnership; + private const MAX_CSV_FILE_KILOBYTES = 5120; + + private const MAX_CSV_IMPORT_QUESTIONS = 500; + + private const MAX_CSV_ANSWERS_PER_QUESTION = 20; + private function redirectWhenQuizContentLocked(Quiz $quiz): RedirectResponse { return redirect() @@ -62,8 +69,28 @@ public function index(Quiz $quiz): View $questions = $quiz->questions()->with('answers')->get(); $isContentLocked = $quiz->hasLockedContent(); $canAddQuestion = ! $isContentLocked && $this->canAddQuestion($quiz); + $user = auth()->user(); + $questionImportLimit = self::MAX_CSV_IMPORT_QUESTIONS; - return view('questions.index', compact('quiz', 'questions', 'canAddQuestion', 'isContentLocked')); + if ($user && ! $user->isAdmin()) { + $questionImportLimit = min( + self::MAX_CSV_IMPORT_QUESTIONS, + max(0, (int) $user->max_questions_per_quiz - $questions->count()) + ); + } + + $questionsWithImagesCount = $questions + ->filter(fn (Question $question): bool => is_string($question->image) && $question->image !== '') + ->count(); + + return view('questions.index', compact( + 'quiz', + 'questions', + 'canAddQuestion', + 'isContentLocked', + 'questionImportLimit', + 'questionsWithImagesCount' + )); } /** @@ -337,7 +364,7 @@ private function syncAnswers(Question $question, Collection $answersData): void * Import multiple questions from a CSV file. * * Expected headers: text, answer_1, answer_2, ..., correct_answers - * Limit: 20 questions per import + * Limit: the authenticated teacher's remaining quota, up to 500 questions per file. */ public function importCsv(Request $request, Quiz $quiz): RedirectResponse { @@ -357,43 +384,14 @@ public function importCsv(Request $request, Quiz $quiz): RedirectResponse } $request->validate([ - 'questions_csv' => 'required|file|mimes:csv,txt|max:1024', + 'questions_csv' => 'required|file|mimes:csv,txt|max:'.self::MAX_CSV_FILE_KILOBYTES, ]); $file = $request->file('questions_csv'); - $lines = array_map('str_getcsv', file($file->getRealPath())); - - if (empty($lines)) { - return back()->with('error', 'The uploaded CSV is empty.'); - } - - $headers = $this->normalizeQuestionImportHeaders($lines[0]); - $answerColumns = $this->extractQuestionImportAnswerColumns($headers); - $headerError = $this->validateQuestionImportHeaders($headers, $answerColumns); - - if ($headerError !== null) { - return back()->with('error', $headerError); - } - - $rows = array_slice($lines, 1); - $parsedRows = []; + $parsedRows = $this->parseQuestionImportFile($file->getRealPath(), $user); - foreach ($rows as $rowIndex => $row) { - if ($this->isQuestionImportRowEmpty($row)) { - continue; - } - - $parsedRow = $this->parseQuestionImportRow($row, $headers, $answerColumns, $rowIndex + 2, $user); - - if (is_string($parsedRow)) { - return back()->with('error', $parsedRow); - } - - $parsedRows[] = $parsedRow; - } - - if (count($parsedRows) > 20) { - return back()->with('error', 'Maximum of 20 questions allowed per CSV import.'); + if (is_string($parsedRows)) { + return back()->with('error', $parsedRows); } if ($user && ! $user->isAdmin()) { @@ -407,7 +405,7 @@ public function importCsv(Request $request, Quiz $quiz): RedirectResponse } if ($parsedRows === []) { - return back()->with('error', 'No valid questions were imported.'); + return back()->with('error', __('controllers.question_csv_no_valid_rows')); } DB::transaction(function () use ($quiz, $parsedRows): void { @@ -419,7 +417,169 @@ public function importCsv(Request $request, Quiz $quiz): RedirectResponse $created = count($parsedRows); - return back()->with('success', "$created question(s) imported successfully."); + return back()->with('success', trans_choice('controllers.question_csv_imported', $created, [ + 'count' => $created, + ])); + } + + /** + * Export question text, answers, and correct-answer positions in the same CSV contract accepted by importCsv. + */ + public function exportCsv(Quiz $quiz): StreamedResponse + { + $this->authorizeQuizAccess($quiz); + + $questions = $quiz->questions() + ->with(['answers' => fn ($query) => $query->orderBy('id')]) + ->orderBy('id') + ->get(); + $maximumAnswerCount = max( + 2, + (int) $questions->max( + fn (Question $question): int => $question->answers->count() + ) + ); + $filename = "quiz_questions_{$quiz->id}.csv"; + + return response()->streamDownload(function () use ($questions, $maximumAnswerCount): void { + $output = fopen('php://output', 'wb'); + + if ($output === false) { + return; + } + + fwrite($output, "\xEF\xBB\xBF"); + + $headers = ['text']; + for ($answerNumber = 1; $answerNumber <= $maximumAnswerCount; $answerNumber++) { + $headers[] = "answer_{$answerNumber}"; + } + $headers[] = 'correct_answers'; + fputcsv($output, $headers, ',', '"', '', "\r\n"); + + foreach ($questions as $question) { + $answers = $question->answers->values(); + $row = [$this->escapeSpreadsheetFormula((string) $question->text)]; + + for ($answerIndex = 0; $answerIndex < $maximumAnswerCount; $answerIndex++) { + $answerText = (string) ($answers->get($answerIndex)?->text ?? ''); + $row[] = $this->escapeSpreadsheetFormula($answerText); + } + + $row[] = $answers + ->map(fn ($answer, int $index): ?int => $answer->is_correct ? $index + 1 : null) + ->filter() + ->implode(','); + + fputcsv($output, $row, ',', '"', '', "\r\n"); + } + + fclose($output); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'X-Content-Type-Options' => 'nosniff', + ]); + } + + /** + * Parse the uploaded CSV one logical record at a time, including quoted multiline fields. + * + * @return list, answers: Collection}>|string + */ + private function parseQuestionImportFile(string|false $path, ?User $user): array|string + { + if (! is_string($path) || $path === '') { + return __('controllers.question_csv_read_failed'); + } + + $handle = fopen($path, 'rb'); + + if ($handle === false) { + return __('controllers.question_csv_read_failed'); + } + + try { + $headerRow = fgetcsv($handle, 0, ',', '"', ''); + + if ($headerRow === false || $this->isQuestionImportRowEmpty($headerRow)) { + return __('controllers.question_csv_empty'); + } + + if (! $this->isUtf8CsvRow($headerRow)) { + return __('controllers.question_csv_utf8_required'); + } + + $headers = $this->normalizeQuestionImportHeaders($headerRow); + $answerColumns = $this->extractQuestionImportAnswerColumns($headers); + $headerError = $this->validateQuestionImportHeaders($headers, $answerColumns); + + if ($headerError !== null) { + return $headerError; + } + + $parsedRows = []; + $recordNumber = 1; + + while (($row = fgetcsv($handle, 0, ',', '"', '')) !== false) { + $recordNumber++; + + if ($this->isQuestionImportRowEmpty($row)) { + continue; + } + + if (! $this->isUtf8CsvRow($row)) { + return __('controllers.question_csv_row_utf8_required', ['row' => $recordNumber]); + } + + if (count($parsedRows) >= self::MAX_CSV_IMPORT_QUESTIONS) { + return __('controllers.question_csv_too_many_rows', [ + 'max' => self::MAX_CSV_IMPORT_QUESTIONS, + ]); + } + + $parsedRow = $this->parseQuestionImportRow( + $row, + $headers, + $answerColumns, + $recordNumber, + $user + ); + + if (is_string($parsedRow)) { + return $parsedRow; + } + + $parsedRows[] = $parsedRow; + } + + return $parsedRows; + } finally { + fclose($handle); + } + } + + /** + * Keep spreadsheet applications from evaluating teacher-authored text as a formula. + */ + private function escapeSpreadsheetFormula(string $value): string + { + return preg_match('/^[=+\-@]/u', ltrim($value)) === 1 ? "\t".$value : $value; + } + + /** + * Validate encoding before values reach database-backed question models. + * + * @param array $row + */ + private function isUtf8CsvRow(array $row): bool + { + foreach ($row as $value) { + if (! mb_check_encoding((string) $value, 'UTF-8')) { + return false; + } + } + + return true; } /** @@ -469,8 +629,23 @@ private function extractQuestionImportAnswerColumns(array $headers): array */ private function validateQuestionImportHeaders(array $headers, array $answerColumns): ?string { - if (! in_array('text', $headers, true) || ! in_array('correct_answers', $headers, true) || count($answerColumns) < 2) { - return 'CSV must contain the headers: text, answer_1, answer_2, ..., correct_answers.'; + $hasSingleTextColumn = count(array_keys($headers, 'text', true)) === 1; + $hasSingleCorrectAnswersColumn = count(array_keys($headers, 'correct_answers', true)) === 1; + $answerNumbersAreSequential = array_keys($answerColumns) === range(1, count($answerColumns)); + + if ( + ! $hasSingleTextColumn + || ! $hasSingleCorrectAnswersColumn + || count($answerColumns) < 2 + || ! $answerNumbersAreSequential + ) { + return __('controllers.question_csv_invalid_headers'); + } + + if (count($answerColumns) > self::MAX_CSV_ANSWERS_PER_QUESTION) { + return __('controllers.question_csv_too_many_answer_columns', [ + 'max' => self::MAX_CSV_ANSWERS_PER_QUESTION, + ]); } return null; @@ -507,7 +682,7 @@ private function parseQuestionImportRow(array $row, array $headers, array $answe $questionText = trim((string) ($row[$textIndex] ?? '')); if ($questionText === '') { - return "Row {$rowNumber}: question text is required."; + return __('controllers.question_csv_text_required', ['row' => $rowNumber]); } $answersData = collect($answerColumns) @@ -521,17 +696,24 @@ private function parseQuestionImportRow(array $row, array $headers, array $answe ->values(); if ($answersData->count() < 2) { - return "Row {$rowNumber}: each question must include at least two answers."; + return __('controllers.question_csv_minimum_answers', ['row' => $rowNumber]); } - if ($user && ! $user->isAdmin() && $answersData->count() > $user->max_answers_per_question) { - return "Row {$rowNumber}: you can import up to {$user->max_answers_per_question} answers per question."; + $answerLimit = $user && ! $user->isAdmin() + ? min(self::MAX_CSV_ANSWERS_PER_QUESTION, (int) $user->max_answers_per_question) + : self::MAX_CSV_ANSWERS_PER_QUESTION; + + if ($answersData->count() > $answerLimit) { + return __('controllers.question_csv_answer_limit', [ + 'row' => $rowNumber, + 'max' => $answerLimit, + ]); } $correctAnswersRaw = trim((string) ($row[$correctAnswersIndex] ?? '')); if ($correctAnswersRaw === '') { - return "Row {$rowNumber}: correct_answers is required."; + return __('controllers.question_csv_correct_required', ['row' => $rowNumber]); } $correctAnswerNumbers = collect(explode(',', $correctAnswersRaw)) @@ -540,12 +722,12 @@ private function parseQuestionImportRow(array $row, array $headers, array $answe ->values(); if ($correctAnswerNumbers->isEmpty()) { - return "Row {$rowNumber}: correct_answers must contain answer numbers like 1 or 1,3."; + return __('controllers.question_csv_correct_format', ['row' => $rowNumber]); } foreach ($correctAnswerNumbers as $correctAnswerNumber) { if (! ctype_digit($correctAnswerNumber) || (int) $correctAnswerNumber < 1) { - return "Row {$rowNumber}: correct_answers must contain answer numbers like 1 or 1,3."; + return __('controllers.question_csv_correct_format', ['row' => $rowNumber]); } } @@ -554,14 +736,14 @@ private function parseQuestionImportRow(array $row, array $headers, array $answe ->values(); if ($correctAnswerNumbers->unique()->count() !== $correctAnswerNumbers->count()) { - return "Row {$rowNumber}: correct_answers cannot contain duplicate answer numbers."; + return __('controllers.question_csv_correct_duplicates', ['row' => $rowNumber]); } $availableAnswerNumbers = $answersData->pluck('number')->all(); $missingAnswerNumbers = array_values(array_diff($correctAnswerNumbers->all(), $availableAnswerNumbers)); if ($missingAnswerNumbers !== []) { - return "Row {$rowNumber}: correct_answers references missing answer columns."; + return __('controllers.question_csv_correct_missing', ['row' => $rowNumber]); } return [ diff --git a/docs/index.html b/docs/index.html index ff2b651..78ccd2d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -54,7 +54,7 @@

01. Installation

02. Upgrade

-

Follow the professional upgrade path from v2.1.4 to v2.2.0.

+

Follow the professional upgrade path from v2.2.0 to v2.2.1.

@@ -82,7 +82,7 @@

03. Security

installTitle: "01. Installation", installText: "Set up EXAMS, configure the environment, and prepare the application for use.", upgradeTitle: "02. Upgrade", - upgradeText: "Follow the professional upgrade path from v2.1.4 to v2.2.0.", + upgradeText: "Follow the professional upgrade path from v2.2.0 to v2.2.1.", securityTitle: "03. Security", securityText: "Security, privacy, and compliance notes for operating the application responsibly." }, @@ -99,7 +99,7 @@

03. Security

installTitle: "01. Εγκατάσταση", installText: "Ρυθμίστε το EXAMS, προετοιμάστε το περιβάλλον και ετοιμάστε την εφαρμογή για χρήση.", upgradeTitle: "02. Αναβάθμιση", - upgradeText: "Ακολουθήστε τη διαδρομή αναβάθμισης από v2.1.4 σε v2.2.0.", + upgradeText: "Ακολουθήστε τη διαδρομή αναβάθμισης από v2.2.0 σε v2.2.1.", securityTitle: "03. Ασφάλεια", securityText: "Σημειώσεις ασφάλειας, απορρήτου και συμμόρφωσης για υπεύθυνη λειτουργία της εφαρμογής." } diff --git a/docs/quiz-create-edit.html b/docs/quiz-create-edit.html index 39104d0..b437c2a 100644 --- a/docs/quiz-create-edit.html +++ b/docs/quiz-create-edit.html @@ -159,7 +159,28 @@

Editing an existing quiz

Bulk participation and administrator options

- Teachers can choose either bulk examination with pre-registered slots or a public anonymous pool. Both modes enforce the account participant limit and cannot be combined. + Teachers can choose either bulk examination with pre-registered slots or a public anonymous pool. Both modes enforce the account participant limit, are mutually exclusive, and are intended for participants whose details may not be known in advance. +

+
+
+ Bulk examination with pre-registered slots +

Creates separate exam slots with unique PINs and QR Codes. Each printed card can be handed to a participant, who writes their name on it for an external association.

+
+
+ Public anonymous pool +

Uses one common public QR/link. A temporary anonymous slot is assigned when someone enters, while a permanent result is stored only after final submission and within the configured capacity.

+
+
+
+ Bulk participation modes available to teachers +
The two teacher participation modes are mutually exclusive and use the participant limit configured for the account.
+
+
+

Important: bulk examination with pre-registered slots and public anonymous pool mode cannot be enabled together. Learning and second-screen modes cannot be combined with either of these special participation modes.

+
+

Administrator-only quiz options

+

+ The following controls are visible only to administrators. They are separate from the bulk participation modes available to teachers.

@@ -170,17 +191,10 @@

Bulk participation and administrator options

Second screen and mobile controller

Starts a registered examinee on a shared screen while answers are given from the connected mobile controller.

-
- Special participation modes -

Bulk examination creates pre-registered exam slots with unique PINs and QR Codes. Public anonymous pool mode uses one public link with a defined capacity.

-
-
-
-

Important: bulk examination with pre-registered slots and public anonymous pool mode cannot be enabled together. Learning and second-screen modes cannot be combined with either of these special participation modes.

- Bulk participation and administrator quiz options -
Quota-limited bulk participation is available to teachers, while certificate verification and second-screen mode remain administrator-only.
+ Quiz options available only to administrators +
Public certificate verification and second-screen/mobile-controller mode remain administrator-only controls.
@@ -196,7 +210,7 @@

Bulk participation and administrator options

description: "How educators create and edit quizzes in LabSchool Exams, including quota-limited bulk participation and administrator-only controls.", eyebrow: "Second guide", title: "Quiz Creation and Editing", - lead: "This page explains how a teacher creates a quiz, what each group of options controls, and which extra experimental modes are available only to administrators.", + lead: "This page explains how a teacher creates a quiz, what each group of options controls, and which controls are available to teachers or only to administrators.", collectionTitle: "Starting from the personal quiz collection", collectionTextOne: "The teacher starts from the personal quiz collection. From there, a new quiz can be created, an existing quiz can be edited, or a shared example quiz can be copied as a new personal quiz.", collectionTextTwo: "The example quiz is useful for a first test because it already has structure and content. A teacher can preview it or copy it, while administrators can also edit platform examples directly.", @@ -234,22 +248,27 @@

Bulk participation and administrator options

editTextTwo: "If a quiz already has submitted or locked content, the application may restrict content changes. In that case, only safer operational settings, such as status and selected administrator controls, remain editable.", editCaption: "The edit page keeps the same structure so the teacher can find the same settings again.", adminTitle: "Bulk participation and administrator options", - adminTextOne: "Teachers can choose either bulk examination with pre-registered slots or a public anonymous pool. Both modes enforce the account participant limit and cannot be combined.", + adminTextOne: "Teachers can choose either bulk examination with pre-registered slots or a public anonymous pool. Both modes enforce the account participant limit, are mutually exclusive, and are intended for participants whose details may not be known in advance.", + bulkTitle: "Bulk examination with pre-registered slots", + bulkText: "Creates separate exam slots with unique PINs and QR Codes. Each printed card can be handed to a participant, who writes their name on it for an external association.", + publicPoolTitle: "Public anonymous pool", + publicPoolText: "Uses one common public QR/link. A temporary anonymous slot is assigned when someone enters, while a permanent result is stored only after final submission and within the configured capacity.", + participationCaption: "The two teacher participation modes are mutually exclusive and use the participant limit configured for the account.", + adminOnlyTitle: "Administrator-only quiz options", + adminOnlyText: "The following controls are visible only to administrators. They are separate from the bulk participation modes available to teachers.", certificateTitle: "Public certificate verification", certificateText: "Adds QR/public verification for certificates. The document remains evidence of assessment, not an official certification.", secondScreenTitle: "Second screen and mobile controller", secondScreenText: "Starts a registered examinee on a shared screen while answers are given from the connected mobile controller.", - anonymousTitle: "Special participation modes", - anonymousText: "Bulk examination creates pre-registered exam slots with unique PINs and QR Codes. Public anonymous pool mode uses one public link with a defined capacity.", adminNote: "Important: bulk examination with pre-registered slots and public anonymous pool mode cannot be enabled together. Learning and second-screen modes cannot be combined with either of these special participation modes.", - adminCaption: "Quota-limited bulk participation is available to teachers, while certificate verification and second-screen mode remain administrator-only." + adminCaption: "Public certificate verification and second-screen/mobile-controller mode remain administrator-only controls." }, el: { pageTitle: "Δημιουργία και Επεξεργασία Quiz", - description: "Πώς οι εκπαιδευτικοί δημιουργούν και επεξεργάζονται quiz στο LabSchool Exams, μαζί με τις επιλογές εκπαιδευτικών και τις πειραματικές λειτουργίες διαχειριστών.", + description: "Πώς οι εκπαιδευτικοί δημιουργούν και επεξεργάζονται quiz στο LabSchool Exams, μαζί με τη μαζική συμμετοχή και τις επιλογές που παραμένουν διαθέσιμες μόνο στους διαχειριστές.", eyebrow: "Δεύτερος οδηγός", title: "Δημιουργία και Επεξεργασία Quiz", - lead: "Η σελίδα αυτή εξηγεί πώς ένας εκπαιδευτικός δημιουργεί ένα quiz, τι ελέγχει κάθε ομάδα επιλογών και ποιες επιπλέον πειραματικές λειτουργίες είναι διαθέσιμες μόνο στους διαχειριστές.", + lead: "Η σελίδα αυτή εξηγεί πώς ένας εκπαιδευτικός δημιουργεί ένα quiz, τι ελέγχει κάθε ομάδα επιλογών και ποιες ρυθμίσεις είναι διαθέσιμες στους εκπαιδευτικούς ή μόνο στους διαχειριστές.", collectionTitle: "Ξεκινώντας από την προσωπική συλλογή quiz", collectionTextOne: "Ο εκπαιδευτικός ξεκινά από την προσωπική συλλογή quiz. Από εκεί μπορεί να δημιουργήσει νέο quiz, να επεξεργαστεί ένα υπάρχον ή να αντιγράψει ένα κοινό παράδειγμα ως νέο προσωπικό quiz.", collectionTextTwo: "Το παράδειγμα quiz είναι χρήσιμο για την πρώτη δοκιμή, γιατί έχει ήδη δομή και περιεχόμενο. Ο εκπαιδευτικός μπορεί να το προεπισκοπήσει ή να το αντιγράψει, ενώ οι διαχειριστές μπορούν επίσης να επεξεργαστούν απευθείας τα παραδείγματα πλατφόρμας.", @@ -287,15 +306,20 @@

Bulk participation and administrator options

editTextTwo: "Αν ένα quiz έχει ήδη υποβολές ή κλειδωμένο περιεχόμενο, η εφαρμογή μπορεί να περιορίσει τις αλλαγές περιεχομένου. Τότε παραμένουν διαθέσιμες κυρίως ασφαλέστερες λειτουργικές ρυθμίσεις, όπως η κατάσταση και ορισμένες επιλογές διαχειριστή.", editCaption: "Η σελίδα επεξεργασίας κρατά την ίδια δομή, ώστε ο εκπαιδευτικός να βρίσκει ξανά τις ίδιες ρυθμίσεις.", adminTitle: "Μαζική συμμετοχή και επιλογές διαχειριστή", - adminTextOne: "Οι εκπαιδευτικοί μπορούν να επιλέξουν είτε μαζική εξέταση με προκαταχωρισμένες θέσεις είτε δημόσιο ανώνυμο σύνολο. Και οι δύο λειτουργίες εφαρμόζουν το όριο εξεταζομένων του λογαριασμού και δεν συνδυάζονται.", + adminTextOne: "Οι εκπαιδευτικοί μπορούν να επιλέξουν είτε μαζική εξέταση με προκαταχωρισμένες θέσεις είτε δημόσιο ανώνυμο σύνολο. Και οι δύο λειτουργίες εφαρμόζουν το όριο εξεταζομένων του λογαριασμού, είναι αμοιβαία αποκλειόμενες και προορίζονται για συμμετέχοντες των οποίων τα στοιχεία μπορεί να μην είναι γνωστά εκ των προτέρων.", + bulkTitle: "Μαζική εξέταση με προκαταχωρισμένες θέσεις", + bulkText: "Δημιουργεί χωριστές θέσεις εξέτασης με μοναδικά PIN και QR Codes. Κάθε εκτυπωμένη κάρτα μπορεί να δοθεί σε έναν συμμετέχοντα, ο οποίος γράφει επάνω το όνομά του για την αντιστοίχιση εκτός εφαρμογής.", + publicPoolTitle: "Δημόσιο ανώνυμο σύνολο εξεταζόμενων", + publicPoolText: "Χρησιμοποιεί ένα κοινό δημόσιο QR/link. Κατά την είσοδο δεσμεύεται προσωρινή ανώνυμη θέση, ενώ μόνιμο αποτέλεσμα αποθηκεύεται μόνο μετά την τελική υποβολή και μέσα στη δηλωμένη χωρητικότητα.", + participationCaption: "Οι δύο λειτουργίες συμμετοχής των εκπαιδευτικών είναι αμοιβαία αποκλειόμενες και χρησιμοποιούν το όριο εξεταζομένων που έχει οριστεί για τον λογαριασμό.", + adminOnlyTitle: "Επιλογές quiz μόνο για διαχειριστές", + adminOnlyText: "Οι παρακάτω ρυθμίσεις εμφανίζονται μόνο στους διαχειριστές και είναι ανεξάρτητες από τις λειτουργίες μαζικής συμμετοχής των εκπαιδευτικών.", certificateTitle: "Δημόσια επαλήθευση βεβαίωσης", certificateText: "Προσθέτει QR και δημόσιο έλεγχο γνησιότητας για βεβαιώσεις. Το έγγραφο παραμένει βεβαίωση αξιολόγησης και όχι επίσημη πιστοποίηση.", secondScreenTitle: "Τηλεόραση και χειριστήριο κινητού", secondScreenText: "Ξεκινά έναν καταχωρημένο εξεταζόμενο σε κοινή οθόνη, ενώ οι απαντήσεις δίνονται από συνδεδεμένο κινητό χειριστήριο.", - anonymousTitle: "Ειδικές λειτουργίες συμμετοχής", - anonymousText: "Η μαζική εξέταση δημιουργεί προκαταχωρισμένες θέσεις με μοναδικά PIN και QR Codes. Το δημόσιο ανώνυμο σύνολο χρησιμοποιεί έναν δημόσιο σύνδεσμο με συγκεκριμένη χωρητικότητα.", adminNote: "Σημαντικό: η μαζική εξέταση με προκαταχωρισμένες θέσεις και το δημόσιο ανώνυμο σύνολο δεν μπορούν να ενεργοποιηθούν μαζί. Η λειτουργία εκμάθησης και η λειτουργία δεύτερης οθόνης δεν συνδυάζονται με καμία από αυτές τις ειδικές λειτουργίες συμμετοχής.", - adminCaption: "Η μαζική συμμετοχή με όριο είναι διαθέσιμη στους εκπαιδευτικούς, ενώ η επαλήθευση βεβαιώσεων και η δεύτερη οθόνη παραμένουν μόνο για διαχειριστές." + adminCaption: "Η δημόσια επαλήθευση βεβαίωσης και η λειτουργία τηλεόρασης με χειριστήριο κινητού παραμένουν διαθέσιμες μόνο στους διαχειριστές." } } }; diff --git a/docs/quiz-questions-answers.html b/docs/quiz-questions-answers.html index 91dee15..aa96997 100644 --- a/docs/quiz-questions-answers.html +++ b/docs/quiz-questions-answers.html @@ -4,7 +4,7 @@ Quiz Questions and Answers - + @@ -14,7 +14,7 @@ - + @@ -24,7 +24,7 @@ - + @@ -36,7 +36,7 @@ Third guide

Quiz Questions and Answers

- This page explains how a teacher manages the questions of a quiz, adds possible answers, marks the correct answer or answers, and imports questions in bulk. + This page explains how a teacher manages the questions of a quiz, adds possible answers, marks the correct answer or answers, and exports or imports question content with CSV.

@@ -112,30 +112,30 @@

Editing questions and answers

-

Bulk import with CSV

+

Exporting and importing questions with CSV

- When a teacher has many questions ready in a spreadsheet, the CSV import can create several questions at once. The expected format is simple: question text, answer columns, and a final column that says which answer numbers are correct. + A teacher can export the questions of one quiz and import the same file into another. The UTF-8 file contains question text, answer choices, and the positions of the correct answers, but no participant, result, access, or quiz-setting data.

- The CSV headers must follow this pattern: text, answer_1, answer_2, ..., correct_answers. The correct_answers value can be a single number such as 1, or multiple numbers such as 1,3. + The headers follow the pattern text, answer_1, answer_2, ..., correct_answers. The correct_answers value can be one number such as 1, or multiple numbers such as 1,3. Imported questions are appended to the target quiz without replacing existing questions.

- Use UTF-8 -

Save the CSV as UTF-8 so Greek and English text import correctly.

+ Export for reuse +

Download a ready-to-import CSV containing all question text, answer choices, and correct-answer positions.

- Up to 20 rows -

Each import accepts up to 20 question rows.

+ Import a compatible file +

Use an exported file or complete the provided CSV template. The whole file is rejected if any row is invalid.

- Respect limits -

Teacher account limits still apply to total questions and answers.

+ Limits and images +

The teacher's remaining limits apply, with a safety cap of 500 questions per file. Question images must be added separately.

- CSV import panel under question list -
The CSV import panel appears under the question list and includes a template download link.
+ Question export and import options using CSV +
The question page separates CSV export and import into two clear, compatible steps.
@@ -155,10 +155,10 @@

When question editing is locked

translations: { en: { pageTitle: "Quiz Questions and Answers", - description: "How educators add, edit, delete, and import quiz questions and answers in LabSchool Exams.", + description: "How educators add, edit, delete, export, and import quiz questions and answers in LabSchool Exams.", eyebrow: "Third guide", title: "Quiz Questions and Answers", - lead: "This page explains how a teacher manages the questions of a quiz, adds possible answers, marks the correct answer or answers, and imports questions in bulk.", + lead: "This page explains how a teacher manages the questions of a quiz, adds possible answers, marks the correct answer or answers, and exports or imports question content with CSV.", listTitle: "The question list of a quiz", listTextOne: "Each quiz has its own question list. From this page, the teacher can add a new question, edit an existing one, or delete a question that is no longer needed.", listTextTwo: "Every question card shows the question text and how many correct answers it has. This helps the teacher quickly check whether the quiz is ready before sending it to examinees.", @@ -181,25 +181,25 @@

When question editing is locked

editTextOne: "Editing a question uses the same form as creation, but with the current data already filled in. The teacher can correct wording, add or remove answers, and change which answers are marked as correct.", editTextTwo: "The application keeps the answer structure consistent: empty answer rows are ignored, invalid answer references are rejected, and the number of correct answers is recalculated after saving.", editCaption: "Editing an existing question with an image and answer list.", - csvTitle: "Bulk import with CSV", - csvTextOne: "When a teacher has many questions ready in a spreadsheet, the CSV import can create several questions at once. The expected format is simple: question text, answer columns, and a final column that says which answer numbers are correct.", - csvTextTwo: "The CSV headers must follow this pattern: text, answer_1, answer_2, ..., correct_answers. The correct_answers value can be a single number such as 1, or multiple numbers such as 1,3.", - csvStepOneTitle: "Use UTF-8", - csvStepOneText: "Save the CSV as UTF-8 so Greek and English text import correctly.", - csvStepTwoTitle: "Up to 20 rows", - csvStepTwoText: "Each import accepts up to 20 question rows.", - csvStepThreeTitle: "Respect limits", - csvStepThreeText: "Teacher account limits still apply to total questions and answers.", - csvCaption: "The CSV import panel appears under the question list and includes a template download link.", + csvTitle: "Exporting and importing questions with CSV", + csvTextOne: "A teacher can export the questions of one quiz and import the same file into another. The UTF-8 file contains question text, answer choices, and the positions of the correct answers, but no participant, result, access, or quiz-setting data.", + csvTextTwo: "The headers follow the pattern text, answer_1, answer_2, ..., correct_answers. The correct_answers value can be one number such as 1, or multiple numbers such as 1,3. Imported questions are appended to the target quiz without replacing existing questions.", + csvStepOneTitle: "Export for reuse", + csvStepOneText: "Download a ready-to-import CSV containing all question text, answer choices, and correct-answer positions.", + csvStepTwoTitle: "Import a compatible file", + csvStepTwoText: "Use an exported file or complete the provided CSV template. The whole file is rejected if any row is invalid.", + csvStepThreeTitle: "Limits and images", + csvStepThreeText: "The teacher's remaining limits apply, with a safety cap of 500 questions per file. Question images must be added separately.", + csvCaption: "The question page separates CSV export and import into two clear, compatible steps.", lockedTitle: "When question editing is locked", lockedText: "If a quiz already has locked content, submitted attempts, or data that must remain stable, the application can stop question editing. This protects previous results and prevents changes that would make reports unreliable." }, el: { pageTitle: "Ερωτήσεις και Απαντήσεις Quiz", - description: "Πώς οι εκπαιδευτικοί προσθέτουν, επεξεργάζονται, διαγράφουν και εισάγουν ερωτήσεις και απαντήσεις σε quiz του LabSchool Exams.", + description: "Πώς οι εκπαιδευτικοί προσθέτουν, επεξεργάζονται, διαγράφουν, εξάγουν και εισάγουν ερωτήσεις και απαντήσεις σε quiz του LabSchool Exams.", eyebrow: "Τρίτος οδηγός", title: "Ερωτήσεις και Απαντήσεις Quiz", - lead: "Η σελίδα αυτή εξηγεί πώς ο εκπαιδευτικός διαχειρίζεται τις ερωτήσεις ενός quiz, προσθέτει πιθανές απαντήσεις, σημειώνει τη σωστή ή τις σωστές απαντήσεις και εισάγει ερωτήσεις μαζικά.", + lead: "Η σελίδα αυτή εξηγεί πώς ο εκπαιδευτικός διαχειρίζεται τις ερωτήσεις ενός quiz, προσθέτει πιθανές απαντήσεις, σημειώνει τη σωστή ή τις σωστές απαντήσεις και εξάγει ή εισάγει περιεχόμενο ερωτήσεων με CSV.", listTitle: "Η λίστα ερωτήσεων ενός quiz", listTextOne: "Κάθε quiz έχει τη δική του λίστα ερωτήσεων. Από αυτή τη σελίδα ο εκπαιδευτικός μπορεί να προσθέσει νέα ερώτηση, να επεξεργαστεί μια υπάρχουσα ή να διαγράψει μια ερώτηση που δεν χρειάζεται πλέον.", listTextTwo: "Κάθε πλαίσιο ερώτησης δείχνει το κείμενο της ερώτησης και πόσες σωστές απαντήσεις έχει. Έτσι ο εκπαιδευτικός ελέγχει γρήγορα αν το quiz είναι έτοιμο πριν το δώσει στους εξεταζόμενους.", @@ -222,16 +222,16 @@

When question editing is locked

editTextOne: "Η επεξεργασία ερώτησης χρησιμοποιεί την ίδια φόρμα με τη δημιουργία, αλλά με συμπληρωμένα τα υπάρχοντα δεδομένα. Ο εκπαιδευτικός μπορεί να διορθώσει διατύπωση, να προσθέσει ή να αφαιρέσει απαντήσεις και να αλλάξει ποιες απαντήσεις είναι σωστές.", editTextTwo: "Η εφαρμογή κρατά τη δομή των απαντήσεων συνεπή: οι κενές γραμμές απαντήσεων αγνοούνται, οι μη έγκυρες αναφορές απαντήσεων απορρίπτονται και το πλήθος σωστών απαντήσεων υπολογίζεται ξανά μετά την αποθήκευση.", editCaption: "Επεξεργασία υπάρχουσας ερώτησης με εικόνα και λίστα απαντήσεων.", - csvTitle: "Μαζική εισαγωγή με CSV", - csvTextOne: "Όταν ο εκπαιδευτικός έχει πολλές ερωτήσεις έτοιμες σε υπολογιστικό φύλλο, η εισαγωγή CSV μπορεί να δημιουργήσει πολλές ερωτήσεις μαζί. Η αναμενόμενη μορφή είναι απλή: κείμενο ερώτησης, στήλες απαντήσεων και μια τελική στήλη που δηλώνει ποιοι αριθμοί απαντήσεων είναι σωστοί.", - csvTextTwo: "Οι επικεφαλίδες του CSV πρέπει να ακολουθούν το μοτίβο: text, answer_1, answer_2, ..., correct_answers. Η τιμή correct_answers μπορεί να είναι ένας αριθμός, όπως 1, ή περισσότεροι αριθμοί, όπως 1,3.", - csvStepOneTitle: "Χρήση UTF-8", - csvStepOneText: "Αποθηκεύουμε το CSV ως UTF-8 ώστε ελληνικά και αγγλικά κείμενα να εισαχθούν σωστά.", - csvStepTwoTitle: "Έως 20 γραμμές", - csvStepTwoText: "Κάθε εισαγωγή δέχεται έως 20 γραμμές ερωτήσεων.", - csvStepThreeTitle: "Τήρηση ορίων", - csvStepThreeText: "Τα όρια του λογαριασμού εκπαιδευτικού συνεχίζουν να ισχύουν για το σύνολο ερωτήσεων και απαντήσεων.", - csvCaption: "Η ενότητα εισαγωγής CSV εμφανίζεται κάτω από τη λίστα ερωτήσεων και περιλαμβάνει σύνδεσμο για πρότυπο αρχείο.", + csvTitle: "Εξαγωγή και εισαγωγή ερωτήσεων με CSV", + csvTextOne: "Ο εκπαιδευτικός μπορεί να εξαγάγει τις ερωτήσεις ενός quiz και να εισαγάγει το ίδιο αρχείο σε άλλο. Το αρχείο UTF-8 περιλαμβάνει τα κείμενα των ερωτήσεων, τις επιλογές απαντήσεων και τις θέσεις των σωστών απαντήσεων, αλλά όχι εξεταζόμενους, αποτελέσματα, στοιχεία πρόσβασης ή ρυθμίσεις του quiz.", + csvTextTwo: "Οι επικεφαλίδες ακολουθούν το μοτίβο text, answer_1, answer_2, ..., correct_answers. Η τιμή correct_answers μπορεί να είναι ένας αριθμός, όπως 1, ή περισσότεροι, όπως 1,3. Οι εισαγόμενες ερωτήσεις προστίθενται στο quiz προορισμού χωρίς να αντικαθιστούν τις υπάρχουσες.", + csvStepOneTitle: "Εξαγωγή για επαναχρησιμοποίηση", + csvStepOneText: "Κατεβάζουμε ένα έτοιμο για εισαγωγή CSV με όλα τα κείμενα ερωτήσεων, τις επιλογές απαντήσεων και τις θέσεις των σωστών απαντήσεων.", + csvStepTwoTitle: "Εισαγωγή συμβατού αρχείου", + csvStepTwoText: "Χρησιμοποιούμε ένα εξαγόμενο αρχείο ή συμπληρώνουμε το διαθέσιμο πρότυπο CSV. Αν κάποια εγγραφή είναι λανθασμένη, απορρίπτεται ολόκληρο το αρχείο.", + csvStepThreeTitle: "Όρια και εικόνες", + csvStepThreeText: "Ισχύει το διαθέσιμο υπόλοιπο του εκπαιδευτικού, με ανώτατο όριο ασφαλείας 500 ερωτήσεων ανά αρχείο. Οι εικόνες ερωτήσεων προστίθενται χωριστά.", + csvCaption: "Η σελίδα ερωτήσεων διαχωρίζει την εξαγωγή και την εισαγωγή CSV σε δύο σαφή και συμβατά βήματα.", lockedTitle: "Όταν κλειδώνει η επεξεργασία ερωτήσεων", lockedText: "Αν ένα quiz έχει ήδη κλειδωμένο περιεχόμενο, υποβλημένες προσπάθειες ή δεδομένα που πρέπει να μείνουν σταθερά, η εφαρμογή μπορεί να σταματήσει την επεξεργασία ερωτήσεων. Έτσι προστατεύονται τα προηγούμενα αποτελέσματα και αποφεύγονται αλλαγές που θα έκαναν τις αναφορές αναξιόπιστες." } diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 8f07ea3..b395b0f 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -56,16 +56,16 @@ It excludes: Starting with `v2.1.0`, the workflow also publishes an incremental package for the configured upgrade base tag. -For the current `v2.2.0` release, that path is: +For the current `v2.2.1` release, that path is: ```text -v2.1.4 -> v2.2.0 +v2.2.0 -> v2.2.1 ``` The package is named: ```text -labschool-exams-v2.1.4-to-v2.2.0-upgrade.zip +labschool-exams-v2.2.0-to-v2.2.1-upgrade.zip ``` See [upgrade-packages.md](upgrade-packages.md) for the package contents and operator steps. diff --git a/docs/screenshots/create-edit/10 anonymous.jpg b/docs/screenshots/create-edit/10 anonymous.jpg new file mode 100644 index 0000000..d99712a Binary files /dev/null and b/docs/screenshots/create-edit/10 anonymous.jpg differ diff --git a/docs/screenshots/create-edit/11 admin.jpg b/docs/screenshots/create-edit/11 admin.jpg new file mode 100644 index 0000000..07d1eab Binary files /dev/null and b/docs/screenshots/create-edit/11 admin.jpg differ diff --git a/docs/screenshots/questions/13 csv.jpg b/docs/screenshots/questions/13 csv.jpg new file mode 100644 index 0000000..d57fdde Binary files /dev/null and b/docs/screenshots/questions/13 csv.jpg differ diff --git a/docs/upgrade-packages.html b/docs/upgrade-packages.html index e0ed15d..7bcf899 100644 --- a/docs/upgrade-packages.html +++ b/docs/upgrade-packages.html @@ -45,17 +45,17 @@

Upgrade Packages

What the upgrade system provides

LabSchool Exams releases can provide two package types. The full package is used for a fresh installation or full application replacement. The incremental upgrade package is smaller and is used only when the installed version matches the supported starting version.

-

The current supported incremental path is v2.1.4 to v2.2.0.

+

The current supported incremental path is v2.2.0 to v2.2.1.

-

Packages in a v2.2.0 release

-

For v2.2.0, the release workflow publishes these files.

-
labschool-exams-v2.2.0-full.zip
-labschool-exams-v2.2.0-full.zip.sha256
-labschool-exams-v2.1.4-to-v2.2.0-upgrade.zip
-labschool-exams-v2.1.4-to-v2.2.0-upgrade.zip.sha256
+                

Packages in a v2.2.1 release

+

For v2.2.1, the release workflow publishes these files.

+
labschool-exams-v2.2.1-full.zip
+labschool-exams-v2.2.1-full.zip.sha256
+labschool-exams-v2.2.0-to-v2.2.1-upgrade.zip
+labschool-exams-v2.2.0-to-v2.2.1-upgrade.zip.sha256
 update.json