├── .gitignore ├── CITATION.cff ├── HideQuiz.ipynb ├── LICENSE ├── README.md ├── examples ├── mc-example.gif ├── num-example.gif └── questions.json ├── jupyterquiz ├── __init__.py ├── dynamic │ ├── __init__.py │ ├── capture.py │ ├── display.py │ ├── loader.py │ └── renderer.py ├── js │ ├── async_suffix.js.tpl │ ├── helpers.js │ ├── multiple_choice.js │ ├── numeric.js │ ├── show_questions.js │ ├── static_suffix.js.tpl │ └── string.js └── styles.css ├── mve.ipynb ├── preserve-responses.ipynb ├── previews ├── github-preview.png └── github-preview.psd ├── pyproject.toml ├── schema ├── README ├── mc_schema.json ├── mc_schema.png ├── mc_schema.svg ├── num_schema.json ├── num_schema.png ├── schema.ipynb ├── string_schema.json └── string_schema.png └── test.ipynb /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .DS_Store 132 | 133 | .vscode/ 134 | 135 | .#* 136 | 137 | .virtual_documents/ 138 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.1.0 2 | message: If you use this software, please cite it as below. 3 | authors: 4 | - family-names: Shea 5 | given-names: John 6 | title: JupyterQuiz 7 | version: 2.9.3 8 | date-released: 2025-04-26 9 | 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2025 John M. Shea 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JupyterQuiz 2 | *JupyterQuiz* is a tool for displaying **interactive self-assessment quizzes in Jupyter notebooks and Jupyter Book**. JupyterQuiz was created to enable interactive quizzes for readers of my book [*Foundations of Data Science with Python*](https://t.co/ES9zBUMSQF) [Affiliate Link] 3 | 4 | **Important Note for JupyterLab 4 Users:** *TLDR*: Make sure you are using Jupyter Quiz version 2.8.0 or later. 5 | 6 | There have been two significant changes to Jupyter Lab recently: 7 | 8 | 1) Changes to the math rendering system in JupyterLab 4 have broken the LaTeX 9 | rendering in JupyterQuiz. There is not currently a simple solution, but I have 10 | opened an issue requesting that the necessary methods be made available. Math 11 | should still work in Jupyter Book. A very hacky solution for Jupyter Lab has now 12 | been moved to the main branch staring with 2.8.0. This loads MathJax 3 on top of 13 | the JupyterLab MathJax version. Although this is not the ideal solution, the upstream 14 | problem has not been fixed after many months, and so I felt I had to take this step. 15 | If it breaks anything for you, please let me know. 16 | 17 | 2) Starting with Jupyter 4.2.5, when Markdown cells are rendered, the id tags 18 | will be stripped from any HTML elements. These id tags were needed by 19 | JupyterQuiz users who use hidden spans to embed quizzes in Jupyter notebooks. 20 | This probably affects a small number of JupyterQuiz users. If you are affected, 21 | update to JupyterQuiz 2.8.0 or later and change your `id` tags to `class`, and 22 | everything should work again. 23 | 24 | **Change to precision parameter:** Starting with 2.7.0a3 and 2.8.0, I changed 25 | how the precision parameter effects numerical answers. Prior to these versions, 26 | precision specified a decimal place; from these versions on, it specifies a number of 27 | significant digits. ) 28 | 29 | *JupyterQuiz* is part of my effort to make **open source tools for developing 30 | modern, interactive textbooks**. 31 | * The other part of this effort is my interactive flashcards tool, 32 | [JupyterCards](https://github.com/jmshea/jupytercards). 33 | * You can see both tools in action in the online resources for my textbook [Foundations of Data Science with Python](https://www.fdsp.net). 34 | 35 | If you would like to see a video that introduces these tools and discusses *why* I made them, check out my [JupyterCon 2023 talk on Tools for Interactive Education Experiences in Jupyter Notebooks and Jupyter Books](https://www.youtube.com/watch?v=MDMUiQ2_ZWE). 36 | 37 | These animated GIFs illustrate two of the basic question types in *JupyterQuiz* (a third String type of question was added in April 2025, and I have not created an animated GIF for it yet): 38 | 39 | **Many Choice Question** 40 | 41 | ![Example many-choice question using JupyterQuiz.](https://github.com/jmshea/jupyterquiz/blob/main/examples/mc-example.gif?raw=true) 42 | 43 | --- 44 | 45 | **Numerical Answer Question** 46 | 47 | ![Example numerical answer question using JupyterQuiz.](https://github.com/jmshea/jupyterquiz/blob/main/examples/num-example.gif?raw=true) 48 | 49 | --- 50 | 51 | **Examples using JupyterQuiz** 52 | 53 | This library was built to create interactive questions for the [*Foundations of Data Science with Python*](https://www.amazon.com/Foundations-Data-Science-Python-Chapman/dp/1032350423) book by John M. Shea. Example: [Chapter 3 Self-Assessment Questions](https://www.fdsp.net/03-first-data/summary.html#terminology-review) 54 | 55 | Some other examples I have seen around the web include: 56 | * *Introduction to Python for Humanists* book by W.J.B. Mattingly. Example: [Section 2.2 Introduction to Data Structures, ](https://python-textbook.pythonhumanities.com/01_intro/01_02-03_data_structures.html) 57 | * *Groundwater I* by P. K. Yadav, T. Reimann, and others. Example: [Lecture 1: Course Introduction/Water Cycle of ](https://vibhubatheja.github.io/GW-Book/content/background/03_basic_hydrogeology.html) 58 | * *Sizing and optimization of mechatronic systems* course by Marc Budinger, Scott Delbecq and Félix Pollet. Example: [Lecture 1 Quiz](https://sizinglab.github.io/sizing_course/class/Lecture1/4-quizz.html) 59 | * *Linux en Bioinformatique* by Thomas Denecker & Claire Toffano-Nioche. Example: [Quizz 1](https://ifb-elixirfr.github.io/LinuxEBAII/quizz_01.html) 60 | * *Programmering i Kjemi (Programming in Chemistry?)* by Andreas Haraldsrud. Example: [Quiz 1: Variabler og aritmetikk](https://andreasdh.github.io/programmering-i-kjemi/docs/grunnleggende_programmering/quiz1.html) 61 | * *AnIML: Another Introduction to Machine Learning* by Hunter Schafer. Example: [Chapter 2: Assessing Performance](https://animlbook.com/regression/assessing_performance/index.html) 62 | 63 | If you using JupyterQuiz in a Jupyter Book or other way that is useable on the web, please clone this repository, add your information to the bulleted list in the README.md, and make a pull request for me to include a link to your use of this library. 64 | 65 | The notebook [test.ipynb](test.ipynb) shows more features but must be run on your own local Jupyter or in nbviewer -- GitHub only renders the static HTML that does not include the interactive quizzes. (If viewing on GitHub, there should be a little circle with a minus sign at the top of the file that offers you the ability to launch the notebook in nbviewer.) 66 | 67 | It currently supports two types of quiz questions: 68 | 1. **Multiple/ Many Choice Questions:** Users are given a predefined set of choices and click on answer(s) they believe are correct. 69 | 2. **Numerical:** Users are given a text box in which they can submit answers in decimal or fraction form. 70 | 71 | Each type of question offers different ways to provide feedback to help users understand what they did wrong (or right). 72 | 73 | Quesitons can be loaded from: 74 | * a Python list of dicts, 75 | * a JSON local file, 76 | * via a URL to a JSON file. 77 | 78 | **New as of version 1.6 (9/26/2021): You can now embed the question source (most importantly, the answers) in Jupyter Notebook so that they will not be directly visibile to users!** 79 | 80 | Question source data can be stored in any Markdown cell in a hidden HTML element (such as a span with the display style set to "none"). Questions can be stored as either JSON or base64-encoded JSON (to make them non-human readable). Please see the notebook [HideQuiz.ipynb](HideQuiz.ipynb) for examples of how to use this. 81 | 82 | ## Quiz options 83 | 84 | JupyterQuiz supports a few options: 85 | * num = Number of questions to present. If this option is chosen, the set of questions will be selected at random. 86 | * shuffle_questions = boolean, whether to shuffle order of questions (default False) 87 | * shuffle_answers = boolean, whether to shuffle answers for multiple-choice questions (default True) 88 | * preserve_responses = boolean, whether to output the user responses in a way that is preserved upon reload of the notebook (default False) -- see below 89 | 90 | ### Quiz Formatting 91 | In addtion, it supports additional options for controlling the formatting of the quiz options. 92 | 93 | * `border_radius` = boolean, border radius of question boxes 94 | * `question_alignment` = string, alignment of question text (e.g., "left", "right) 95 | * `max_width` = int, max width of question boxes 96 | 97 | For more fine-grained formatting control of the question text, leaving the 98 | question field the empty string (`""`) will result in only the answers being 99 | displayed. This allows for custom question formatting such as including images, 100 | tables, more complex code examples, etc. Note that this feature works better for 101 | a single question quiz and may not work as well with shuffled quizzes or quiz 102 | questions selected at random. 103 | 104 | Colors can be changed by passing the `colors` keyword argument. Pass in a dictionary of colors that you would like to change. Here is the default dictionary for reference: 105 | 106 | ```{Python} 107 | color_dict = { 108 | '--jq-multiple-choice-bg': '#6f78ffff', # Background for the question part of multiple-choice questions 109 | '--jq-mc-button-bg': '#fafafa', # Background for the buttons when not pressed 110 | '--jq-mc-button-border': '#e0e0e0e0', # Border of the buttons 111 | '--jq-mc-button-inset-shadow': '#555555', # Color of inset shadow for pressed buttons 112 | '--jq-many-choice-bg': '#f75c03ff', # Background for question part of many-choice questions 113 | '--jq-numeric-bg': '#392061ff', # Background for question part of numeric questions 114 | '--jq-numeric-input-bg': '#c0c0c0', # Background for input area of numeric questions 115 | '--jq-numeric-input-label': '#101010', # Color for input of numeric questions 116 | '--jq-numeric-input-shadow': '#999999', # Color for shadow of input area of numeric questions when selected 117 | '--jq-incorrect-color': '#c80202', # Color for incorrect answers 118 | '--jq-correct-color': '#009113', # Color for correct answers 119 | '--jq-text-color': '#fafafa' # Color for question text 120 | } 121 | ``` 122 | 123 | There is one included alternative set of colors to the default colors, which be selected by passing `colors='fdsp'`. 124 | 125 | ## Preserving student responses 126 | 127 | **New as of version 2.0 (7/26/2022): There is now code to enable preserving student responses (for instance, for checking/grading their quizzes). If you want to use this functionality, please read this carefully!** 128 | 129 | To enable this behavior, set `preserve_responses=True` in `display_quiz()` 130 | 131 | This option produces a text ouptut that consists of a question number (based on the question order) along with the chosen answer. Instructions are given at the end of the quiz on how to copy the text output and paste it into a pre-prepared Markdown cell. See [preserve-responses.ipynb](preserve-responses.ipynb) for an example. 132 | 133 | *The requirement that the student copy and paste the text output to preserve it is because of limitations in the exchange of information from the JavaScript side to the Python side. As far as I know, the only way around this requires a plug-in, and I do not want to require that. I will continue to investigate solutions to this in the future.* 134 | 135 | This option is not compatible with `shuffle_questions = True` or setting `num` because these result in the order of the questions being random, which makes no sense when reporting answers vs question number. 136 | 137 | ## Tool for making Multiple/Many Choice Questions 138 | 139 | Dr. WJB Mattingly (@wjbmattingly) has made a [Streamlit App for creating JupyterQuiz question files](https://github.com/wjbmattingly/quiz-generator) in an interactive way without having to edit a JSON file. 140 | It currently supports multiple/many choice questions. 141 | 142 | 143 | ## Installation 144 | 145 | *JupyterQuiz* is available via pip: 146 | 147 | ``` pip install jupyterquiz``` 148 | 149 | ## Multiple/Many Choice Questions 150 | 151 | Multiple/Many Choice questions are defined by a Question, an optional Code block, and a list of possible Answers. Answers include a text component and/or a code block, details on whether the Answer is correct, and Feedback to be displayed for that Answer. The schema for Multiple/Many Choice Questions is shown below: 152 | ![Schema for Multiple/Many Choice Questions in JupyterQuiz](https://github.com/jmshea/jupyterquiz/blob/main/schema/mc_schema.png?raw=true) 153 | 154 | \* = Required parameter, (+) = At least one of these parameters is required 155 | 156 | 157 | 158 | Example JSON for a many-choice question is below: 159 | ```json 160 | { 161 | "question": "Choose all of the following that can be included in Jupyter notebooks?", 162 | "type": "many_choice", 163 | "answers": [ 164 | { 165 | "answer": "Text and graphics output from Python", 166 | "correct": true, 167 | "feedback": "Correct." 168 | }, 169 | { 170 | "answer": "Typeset mathematics", 171 | "correct": true, 172 | "feedback": "Correct." 173 | }, 174 | { 175 | "answer": "Python executable code", 176 | "correct": true, 177 | "feedback": "Correct." 178 | }, 179 | { 180 | "answer": "Formatted text", 181 | "correct": true, 182 | "feedback": "Correct." 183 | }, 184 | { 185 | "answer": "Live snakes via Python", 186 | "correct": false, 187 | "feedback": "I hope not." 188 | } 189 | ] 190 | } 191 | ``` 192 | 193 | ## Numerical Questions 194 | 195 | Numerical questions consist of a Question, an optional Precision, and one or more Answers. Each Answer can be a Value, a Range, or the Default, and each of these can include Feedback text. Values and Ranges can be marked as correct or incorrect. Ranges are in the form [A,B), where endpoint A is included in the range and endpoint B is not included in the range. When Precision is specified, numerical inputs are rounded to the specified precision before comparing to the Answers. The schema for Numerical questions is shown below: 196 | 197 | ![Schema for Numerical Questions in JupyterQuiz](https://github.com/jmshea/jupyterquiz/blob/main/schema/num_schema.png?raw=true) 198 | 199 | \* = Required parameter 200 | 201 | Example JSON for a numerical question is below: 202 | ```json 203 | { 204 | "question": "Enter the value of pi (will be checked to 2 decimal places):", 205 | "type": "numeric", 206 | "precision": 2, 207 | "answers": [ 208 | { 209 | "type": "value", 210 | "value": 3.14, 211 | "correct": true, 212 | "feedback": "Correct." 213 | }, 214 | { 215 | "type": "range", 216 | "range": [ 3.142857, 3.142858], 217 | "correct": true, 218 | "feedback": "True to 2 decimal places, but you know pi is not really 22/7, right?" 219 | }, 220 | { 221 | "type": "range", 222 | "range": [ -100000000, 0], 223 | "correct": false, 224 | "feedback": "pi is the AREA of a circle of radius 1. Try again." 225 | }, 226 | { 227 | "type": "default", 228 | "feedback": "pi is the area of a circle of radius 1. Try again." 229 | } 230 | ] 231 | } 232 | ``` 233 | 234 | ## String Questions 235 | 236 | String questions are specified by setting the "type" property to "string". These questions offer a single question prompt that is specified by the "question" property, but may have multiple possible answers specified by the Answers array. Each Answer in the Answers array is an object that consists of an "answer" (string), a Boolean called "correct", and several optional properties. By default answers case is ignored in comparing submissions to the Answers; however, this can be changed using the boolean "match_case" property. Each answer can have a string "feedback" that is displayed when this answer is matched. Fuzzy matching can be used by specifying a value for the "fuzzy_threshold" property, which should take values between 0 and 1. Fuzzy matching calculates the Levenshtein distance, dividing that by the string length, and then subtracting the result from 1. The resulting value is 1 when the strings match exactly and decreases when the strings are more different. 237 | 238 | You can optionally include a default answer pattern by specifying an object with a "type" property set to "default" and a "feedback" string. If no other answer patterns match the user's submission, the question will be marked incorrect and the provided feedback will be displayed. 239 | 240 | You can also specify an `input_width` property (integer, approximate number of characters) on the question to control the width of the text input field (in em units) in the rendered quiz. 241 | 242 | The schema for String Questions is shown below: 243 | 244 | ![Schema for String Questions](schema/string_schema.png) 245 | 246 | Example JSON for a string question is below: 247 | ```json 248 | { 249 | "question": "Who was the 35th president (1961-63) of the US?", 250 | "type": "string", 251 | "answers": [ 252 | { 253 | "answer": "John F. Kennedy", 254 | "correct": true, 255 | "feedback": "Correct. John F. Kennedy was the 35th president of the U.S.", 256 | "match_case": false, 257 | "fuzzy_threshold": 0.80 258 | }, 259 | { 260 | "answer": "JFK", 261 | "correct": true, 262 | "feedback": "Correct. John F. Kennedy was the 35th president of the U.S." 263 | }, 264 | { 265 | "answer": "Kennedy", 266 | "correct": false, 267 | "feedback": "Please also provide the first name.", 268 | "match_case": false 269 | } 270 | ] 271 | } 272 | ``` 273 | 274 | ## Working with JupyterLite 275 | 276 | This should work with JupyterLite as of version 2.1.2. Here is an example that should work on JupyterLite: 277 | 278 | 279 | ``` 280 | import micropip 281 | await micropip.install('jupyterquiz') 282 | 283 | from jupyterquiz import display_quiz 284 | git_url='https://raw.githubusercontent.com/jmshea/Foundations-of-Data-Science-with-Python/main/questions/' 285 | 286 | display_quiz(git_url+'ch1.json') 287 | ``` 288 | 289 | 290 | **As an Amazon Associate I earn from qualifying purchases.** 291 | -------------------------------------------------------------------------------- /examples/mc-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmshea/jupyterquiz/a9d18e2eba2e0d76a2ff5782138f105e7b71638d/examples/mc-example.gif -------------------------------------------------------------------------------- /examples/num-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmshea/jupyterquiz/a9d18e2eba2e0d76a2ff5782138f105e7b71638d/examples/num-example.gif -------------------------------------------------------------------------------- /examples/questions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "question": "Choose all of the following that can be included in Jupyter notebooks?", 4 | "type": "many_choice", 5 | "answers": [ 6 | { 7 | "answer": "Text and graphics output from Python", 8 | "correct": true, 9 | "feedback": "Correct." 10 | }, 11 | { 12 | "answer": "Typeset mathematics", 13 | "correct": true, 14 | "feedback": "Correct." 15 | }, 16 | { 17 | "answer": "Python executable code", 18 | "correct": true, 19 | "feedback": "Correct." 20 | }, 21 | { 22 | "answer": "Formatted text", 23 | "correct": true, 24 | "feedback": "Correct." 25 | }, 26 | { 27 | "answer": "Live snakes via Python", 28 | "correct": false, 29 | "feedback": "I hope not." 30 | } 31 | ] 32 | }, 33 | { 34 | "question": "Testing parameter to change number of colums for answers: Choose all of the following that can be included in Jupyter notebooks?", 35 | "type": "many_choice", 36 | "answer_cols": 4, 37 | "answers": [ 38 | { 39 | "answer": "Text and graphics output from Python", 40 | "correct": true, 41 | "feedback": "Correct." 42 | }, 43 | { 44 | "answer": "Typeset mathematics", 45 | "correct": true, 46 | "feedback": "Correct." 47 | }, 48 | { 49 | "answer": "Python executable code", 50 | "correct": true, 51 | "feedback": "Correct." 52 | }, 53 | { 54 | "answer": "Formatted text", 55 | "correct": true, 56 | "feedback": "Correct." 57 | }, 58 | { 59 | "answer": "Live snakes via Python", 60 | "correct": false, 61 | "feedback": "I hope not." 62 | } 63 | ] 64 | }, 65 | { 66 | "question": "Which of these are used to create formatted text in Jupyter notebooks?", 67 | "type": "multiple_choice", 68 | "answers": [ 69 | { 70 | "answer": "Wiki markup", 71 | "correct": false, 72 | "feedback": "False." 73 | }, 74 | { 75 | "answer": "SVG", 76 | "correct": false, 77 | "feedback": "False." 78 | }, 79 | { 80 | "answer": "Markdown", 81 | "correct": true, 82 | "feedback": "Correct." 83 | }, 84 | { 85 | "answer": "Rich Text", 86 | "correct": false, 87 | "feedback": "False." 88 | } 89 | ] 90 | }, 91 | { 92 | "question": "Enter the value of $\\pi$ to 2 decimal places.", 93 | "type": "numeric", 94 | "answers": [ 95 | { 96 | "type": "value", 97 | "value": 3.14, 98 | "correct": true, 99 | "feedback": "Correct." 100 | }, 101 | { 102 | "type": "range", 103 | "range": [ 104 | 3.142857, 105 | 3.142858 106 | ], 107 | "correct": true, 108 | "feedback": "True to 2 decimal places, but you know $\\pi$ is not really 22/7, right?" 109 | }, 110 | { 111 | "type": "range", 112 | "range": [ 113 | -100000000, 114 | 0 115 | ], 116 | "correct": false, 117 | "feedback": "$\\pi$ is the AREA of a circle of radius 1. Try again." 118 | }, 119 | { 120 | "type": "default", 121 | "feedback": "$\\pi$ is the area of a circle of radius 1. Try again." 122 | } 123 | ] 124 | }, 125 | { 126 | "question": "Enter the value of $\\pi$ to 2 decimal places.", 127 | "type": "numeric", 128 | "precision": 3, 129 | "answers": [ 130 | { 131 | "type": "value", 132 | "value": 3.14, 133 | "correct": true, 134 | "feedback": "Correct." 135 | }, 136 | { 137 | "type": "range", 138 | "range": [ 139 | 3.142857, 140 | 3.142858 141 | ], 142 | "correct": true, 143 | "feedback": "True to 2 decimal places, but you know $\\pi$ is not really 22/7, right?" 144 | }, 145 | { 146 | "type": "range", 147 | "range": [ 148 | -100000000, 149 | 0 150 | ], 151 | "correct": false, 152 | "feedback": "$\\pi$ is the AREA of a circle of radius 1. Try again." 153 | }, 154 | { 155 | "type": "default", 156 | "feedback": "$\\pi$ is the area of a circle of radius 1. Try again." 157 | } 158 | ] 159 | }, 160 | { 161 | "question": "Determine the output of the following Python code:", 162 | "code": "a=\"1\"\nb=\"2\"\nprint(a+b)", 163 | "type": "multiple_choice", 164 | "answers": [ 165 | { 166 | "answer": "1", 167 | "correct": false, 168 | "feedback": "No. When strings are operated on by +, they are concatenated." 169 | }, 170 | { 171 | "answer": "2", 172 | "correct": false, 173 | "feedback": "No. When strings are operated on by +, they are concatenated." 174 | }, 175 | { 176 | "answer": "3", 177 | "correct": false, 178 | "feedback": "No. When strings are operated on by +, they are concatenated." 179 | }, 180 | { 181 | "answer": "12", 182 | "correct": true, 183 | "feedback": "Yes. The + operator will concatenate the strings \"1\" and \"2\"." 184 | }, 185 | { 186 | "answer": "error", 187 | "correct": false, 188 | "feedback": "No. The + operator for strings performs string concatenation." 189 | } 190 | ] 191 | }, 192 | { 193 | "question": "The variable mylist is a Python list. Choose which code snippet will append the item 3 to mylist.", 194 | "type": "multiple_choice", 195 | "answers": [ 196 | { 197 | "code": "mylist+=3", 198 | "correct": false 199 | }, 200 | { 201 | "code": "mylist+=[3]", 202 | "correct": true 203 | }, 204 | { 205 | "code": "mylist+={3}", 206 | "correct": false 207 | } 208 | ] 209 | }, 210 | { 211 | "question": "Which of these is the ratio of a circle's circumference to its diameter?", 212 | "type": "multiple_choice", 213 | "answers": [ 214 | { 215 | "answer": "$\\pi$", 216 | "correct": true, 217 | "feedback": "Correct." 218 | }, 219 | { 220 | "answer": "$\\frac{22}{7}$", 221 | "correct": false, 222 | "feedback": "$\\frac{22}{7}$ is only an approximation to the true value." 223 | }, 224 | { 225 | "answer": "3", 226 | "correct": false, 227 | "feedback": "This is a crude approximation to the true value." 228 | }, 229 | { 230 | "answer": "$\\tau$", 231 | "correct": false, 232 | "feedback": "True for the ratio of the circle's circumference to its radius, not diameter." 233 | } 234 | ] 235 | } 236 | ] 237 | -------------------------------------------------------------------------------- /jupyterquiz/__init__.py: -------------------------------------------------------------------------------- 1 | '''Module to display dynamic quizzes in Jupyter notebooks and Jupyter Books. Uses JavaScript to provide 2 | interactivity across these different formats and to dynamically update questions from a given URL and 3 | randomly select questions from a pool (if desired) when the cell is rerun (Jupyter notebook) or 4 | page is reloaded Jupyter Book (HTML format). 5 | 6 | Currently supports two question types: Multiple/Many Choice and Numeric 7 | 8 | Created by John M. Shea, copyright 2021-2024 9 | for the book Foundations of Data Science with Python: https://fdsp.net 10 | 11 | All files in the package are distributed under the MIT License 12 | ''' 13 | 14 | __version__ = '2.9.6.2' 15 | from .dynamic import display_quiz, capture_responses 16 | -------------------------------------------------------------------------------- /jupyterquiz/dynamic/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | dynamic subpackage splitting responsibilities of dynamic.py. 3 | """ 4 | from .display import display_quiz 5 | from .capture import capture_responses 6 | 7 | __all__ = ["display_quiz", "capture_responses"] -------------------------------------------------------------------------------- /jupyterquiz/dynamic/capture.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for capturing user responses to quizzes. 3 | """ 4 | import string 5 | import random 6 | from IPython.display import display, Javascript, HTML 7 | 8 | def capture_responses(prev_div_id): 9 | """ 10 | Attempt to capture and display responses from a previous quiz container. 11 | """ 12 | letters = string.ascii_letters 13 | div_id = ''.join(random.choice(letters) for _ in range(12)) 14 | 15 | mydiv = f'
' 16 | javascript = f""" 17 | {{ 18 | var prev = {prev_div_id}; 19 | var container = document.getElementById("{div_id}"); 20 | var responses = prev.querySelector('.JCResponses'); 21 | if (responses) {{ 22 | var respStr = responses.dataset.responses; 23 | container.setAttribute('data-responses', respStr); 24 | var iDiv = document.createElement('div'); 25 | iDiv.id = 'responses' + '{div_id}'; 26 | iDiv.innerText = respStr; 27 | container.appendChild(iDiv); 28 | }} else {{ 29 | container.innerText = 'No Responses Found'; 30 | }} 31 | }} 32 | """ 33 | display(HTML(mydiv)) 34 | display(Javascript(javascript)) -------------------------------------------------------------------------------- /jupyterquiz/dynamic/display.py: -------------------------------------------------------------------------------- 1 | """ 2 | Entry point for displaying quizzes in Jupyter environments. 3 | """ 4 | import string 5 | import random 6 | from IPython.display import display, Javascript, HTML 7 | 8 | from .loader import load_questions_script 9 | from .renderer import render_div, build_styles, build_script 10 | 11 | def display_quiz(ref, num=1_000_000, shuffle_questions=False, 12 | shuffle_answers=True, preserve_responses=False, 13 | border_radius=10, question_alignment="left", 14 | max_width=600, colors=None, load_js=True): 15 | """ 16 | Display an interactive quiz in a Jupyter notebook. 17 | 18 | Parameters are the same as documented in the original dynamic.py. 19 | """ 20 | assert not (shuffle_questions and preserve_responses), \ 21 | "Preserving responses not supported when shuffling questions." 22 | assert num == 1_000_000 or (not preserve_responses), \ 23 | "Preserving responses not supported when num is set." 24 | assert question_alignment in ['left', 'right', 'center'], \ 25 | "question_alignment must be 'left', 'center', or 'right'" 26 | 27 | # Unique identifier for container 28 | letters = string.ascii_letters 29 | div_id = ''.join(random.choice(letters) for _ in range(12)) 30 | 31 | preserve_json = 'true' if preserve_responses else 'false' 32 | 33 | # Default color palette 34 | color_dict = { 35 | '--jq-multiple-choice-bg': '#6f78ffff', 36 | '--jq-mc-button-bg': '#fafafa', 37 | '--jq-mc-button-border': '#e0e0e0e0', 38 | '--jq-mc-button-inset-shadow': '#555555', 39 | '--jq-many-choice-bg': '#f75c03ff', 40 | '--jq-numeric-bg': '#392061ff', 41 | '--jq-numeric-input-bg': '#c0c0c0', 42 | '--jq-numeric-input-label': '#101010', 43 | '--jq-numeric-input-shadow': '#999999', 44 | '--jq-string-bg': '#4c1a57', 45 | '--jq-incorrect-color': '#c80202', 46 | '--jq-correct-color': '#009113', 47 | '--jq-text-color': '#fafafa' 48 | } 49 | # Alternative palette 50 | fdsp_dict = { 51 | '--jq-multiple-choice-bg': '#345995', 52 | '--jq-mc-button-bg': '#fafafa', 53 | '--jq-mc-button-border': '#e0e0e0e0', 54 | '--jq-mc-button-inset-shadow': '#555555', 55 | '--jq-many-choice-bg': '#e26d5a', 56 | '--jq-numeric-bg': '#5bc0eb', 57 | '--jq-numeric-input-bg': '#c0c0c0', 58 | '--jq-numeric-input-label': '#101010', 59 | '--jq-numeric-input-shadow': '#999999', 60 | '--jq-string-bg': '#861657', 61 | '--jq-incorrect-color': '#666666', 62 | '--jq-correct-color': '#87a878', 63 | '--jq-text-color': '#fafafa' 64 | } 65 | if colors == 'fdsp': 66 | color_dict = fdsp_dict 67 | elif isinstance(colors, dict): 68 | color_dict.update(colors) 69 | 70 | # Build loading script 71 | prefix_script, static, url = load_questions_script(ref, div_id) 72 | 73 | # Render HTML container and styles 74 | mydiv = render_div(div_id, shuffle_questions, shuffle_answers, 75 | preserve_responses, num, 76 | max_width, border_radius, question_alignment) 77 | styles = build_styles(div_id, color_dict) 78 | 79 | # Combine all JavaScript 80 | javascript = build_script(prefix_script, static, url, div_id, load_js) 81 | 82 | # Display in notebook 83 | display(HTML(mydiv + styles)) 84 | display(Javascript(javascript)) 85 | -------------------------------------------------------------------------------- /jupyterquiz/dynamic/loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for loading question data from list, file, URL, or DOM element. 3 | """ 4 | import json 5 | import sys 6 | import urllib.request 7 | 8 | # Try to import open_url for Pyodide environments 9 | try: 10 | from pyodide.http import open_url 11 | except ImportError: 12 | try: 13 | from pyodide import open_url 14 | except ImportError: 15 | open_url = None 16 | 17 | def load_questions_script(ref, div_id): 18 | """ 19 | Build JavaScript code prefix to load questions into a variable questions{div_id}. 20 | Returns (script_prefix, static, url), where static is True if questions are embedded, 21 | and url is the reference URL for async loading when static is False. 22 | """ 23 | script = '' 24 | static = True 25 | url = '' 26 | # List of question dicts 27 | if isinstance(ref, list): 28 | script = f"var questions{div_id}=" + json.dumps(ref) 29 | # String reference: DOM id, URL, or filename 30 | elif isinstance(ref, str): 31 | # DOM element containing JSON or base64-encoded JSON 32 | if ref.startswith('#'): 33 | element_id = ref[1:] 34 | script = ( 35 | f'var element = document.getElementById("{element_id}");\n' 36 | f'if (element == null) {{ console.log("ID failed, trying class"); ' 37 | f'var elems = document.getElementsByClassName("{element_id}"); ' 38 | f'element = elems[0]; }}\n' 39 | f'if (element == null) {{ throw new Error("Cannot find element {element_id}"); }}\n' 40 | f'var questions{div_id};\n' 41 | f'try {{ questions{div_id} = JSON.parse(window.atob(element.innerHTML)); }} ' 42 | f'catch(err) {{ console.log("Parsing error, using raw innerHTML"); ' 43 | f'questions{div_id} = JSON.parse(element.innerHTML); }}\n' 44 | f'console.log(questions{div_id});' 45 | ) 46 | # URL fetched asynchronously, with fallback to embedded content 47 | elif ref.lower().startswith("http"): 48 | script = f"var questions{div_id}=" 49 | url = ref 50 | # Embed initial data for fallback 51 | if sys.platform == 'emscripten' and open_url: 52 | text = open_url(url).read() 53 | script += text 54 | else: 55 | with urllib.request.urlopen(url) as response: 56 | for line in response: 57 | script += line.decode('utf-8') 58 | static = False 59 | # Local file path 60 | else: 61 | script = f"var questions{div_id}=" 62 | with open(ref, 'r') as f: 63 | for line in f: 64 | script += line 65 | static = True 66 | else: 67 | raise Exception("First argument must be list, URL, or file ref") 68 | # Terminate statement 69 | script += ";\n\nif (typeof Question === 'undefined') {\n" 70 | return script, static, url 71 | -------------------------------------------------------------------------------- /jupyterquiz/dynamic/renderer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for rendering HTML, CSS, and JavaScript components of the quiz display. 3 | """ 4 | import importlib.resources 5 | from string import Template 6 | 7 | def render_div(div_id, shuffle_questions, shuffle_answers, 8 | preserve_responses, num, max_width, 9 | border_radius, question_alignment): 10 | """ 11 | Build the HTML container div with data attributes and inline style. 12 | """ 13 | preserve_json = 'true' if preserve_responses else 'false' 14 | return ( 15 | f'
' 22 | ) 23 | 24 | def build_styles(div_id, color_dict): 25 | """ 26 | Build the ' 40 | return styles 41 | 42 | def build_script(prefix_script, static, url, div_id, load_js): 43 | """ 44 | Combine the loading prefix, static JS files, and suffix template into a single script. 45 | """ 46 | resource_package = __name__ 47 | package = resource_package.split('.')[0] 48 | script = prefix_script 49 | 50 | # Load all static JS modules 51 | if load_js: 52 | js_dir = importlib.resources.files(package).joinpath('js') 53 | for js_file in sorted(js_dir.iterdir(), key=lambda x: x.name): 54 | if js_file.name.endswith('.js'): 55 | script += js_file.read_bytes().decode('utf-8') 56 | 57 | # Append appropriate suffix behavior 58 | if static: 59 | tpl_path = importlib.resources.files(package).joinpath('js/static_suffix.js.tpl') 60 | tpl = tpl_path.read_text() 61 | script += Template(tpl).substitute(div_id=div_id) 62 | else: 63 | tpl_path = importlib.resources.files(package).joinpath('js/async_suffix.js.tpl') 64 | tpl = tpl_path.read_text() 65 | script += Template(tpl).substitute(url=url, div_id=div_id) 66 | 67 | # Append the final script 68 | script += '\n}\n' 69 | 70 | 71 | 72 | return script 73 | -------------------------------------------------------------------------------- /jupyterquiz/js/async_suffix.js.tpl: -------------------------------------------------------------------------------- 1 | /* 2 | * Attempt to fetch questions JSON, with timeout and fallback to embedded data. 3 | */ 4 | { 5 | const controller = new AbortController(); 6 | const signal = controller.signal; 7 | // Abort fetch after 5 seconds 8 | setTimeout(() => controller.abort(), 5000); 9 | fetch("${url}", { signal }) 10 | .then(response => response.json()) 11 | .then(json => show_questions(json, ${div_id})) 12 | .catch(err => { 13 | console.log("Fetch error or timeout", err); 14 | show_questions(questions${div_id}, ${div_id}); 15 | }); 16 | } -------------------------------------------------------------------------------- /jupyterquiz/js/helpers.js: -------------------------------------------------------------------------------- 1 | // Make a random ID 2 | function makeid(length) { 3 | var result = []; 4 | var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; 5 | var charactersLength = characters.length; 6 | for (var i = 0; i < length; i++) { 7 | result.push(characters.charAt(Math.floor(Math.random() * charactersLength))); 8 | } 9 | return result.join(''); 10 | } 11 | // Convert LaTeX delimiters and markdown links to HTML 12 | function jaxify(string) { 13 | let mystring = string; 14 | let count = 0, count2 = 0; 15 | let loc = mystring.search(/([^\\]|^)(\$)/); 16 | let loc2 = mystring.search(/([^\\]|^)(\$\$)/); 17 | while (loc >= 0 || loc2 >= 0) { 18 | if (loc2 >= 0) { 19 | mystring = mystring.replace(/([^\\]|^)(\$\$)/, count2 % 2 ? '$1\\]' : '$1\\['); 20 | count2++; 21 | } else { 22 | mystring = mystring.replace(/([^\\]|^)(\$)/, count % 2 ? '$1\\)' : '$1\\('); 23 | count++; 24 | } 25 | loc = mystring.search(/([^\\]|^)(\$)/); 26 | loc2 = mystring.search(/([^\\]|^)(\$\$)/); 27 | } 28 | // Replace markdown links 29 | mystring = mystring.replace(//g, 'http$1'); 30 | mystring = mystring.replace(/\[(.*?)\]\((.*?)\)/g, '$1'); 31 | return mystring; 32 | } 33 | 34 | // Base class for question types 35 | class Question { 36 | static registry = {}; 37 | static register(type, cls) { 38 | Question.registry[type] = cls; 39 | } 40 | static create(qa, id, index, options, rootDiv) { 41 | const Cls = Question.registry[qa.type]; 42 | if (!Cls) { 43 | console.error(`No question class registered for type "${qa.type}"`); 44 | return; 45 | } 46 | const q = new Cls(qa, id, index, options, rootDiv); 47 | q.render(); 48 | } 49 | 50 | constructor(qa, id, index, options, rootDiv) { 51 | this.qa = qa; 52 | this.id = id; 53 | this.index = index; 54 | this.options = options; 55 | this.rootDiv = rootDiv; 56 | // wrapper 57 | this.wrapper = document.createElement('div'); 58 | this.wrapper.id = `quizWrap${id}`; 59 | this.wrapper.className = 'Quiz'; 60 | this.wrapper.dataset.qnum = index; 61 | this.wrapper.style.maxWidth = `${options.maxWidth}px`; 62 | rootDiv.appendChild(this.wrapper); 63 | // question container 64 | this.outerqDiv = document.createElement('div'); 65 | this.outerqDiv.id = `OuterquizQn${id}${index}`; 66 | this.wrapper.appendChild(this.outerqDiv); 67 | // question text 68 | this.qDiv = document.createElement('div'); 69 | this.qDiv.id = `quizQn${id}${index}`; 70 | if (qa.question) { 71 | this.qDiv.innerHTML = jaxify(qa.question); 72 | this.outerqDiv.appendChild(this.qDiv); 73 | } 74 | // code block 75 | if (qa.code) { 76 | const codeDiv = document.createElement('div'); 77 | codeDiv.id = `code${id}${index}`; 78 | codeDiv.className = 'QuizCode'; 79 | const pre = document.createElement('pre'); 80 | const codeEl = document.createElement('code'); 81 | codeEl.innerHTML = qa.code; 82 | pre.appendChild(codeEl); 83 | codeDiv.appendChild(pre); 84 | this.outerqDiv.appendChild(codeDiv); 85 | } 86 | // answer container 87 | this.aDiv = document.createElement('div'); 88 | this.aDiv.id = `quizAns${id}${index}`; 89 | this.aDiv.className = 'Answer'; 90 | this.wrapper.appendChild(this.aDiv); 91 | // feedback container (append after answers) 92 | this.fbDiv = document.createElement('div'); 93 | this.fbDiv.id = `fb${id}`; 94 | this.fbDiv.className = 'Feedback'; 95 | this.fbDiv.dataset.answeredcorrect = 0; 96 | } 97 | 98 | render() { 99 | throw new Error('render() not implemented'); 100 | } 101 | 102 | preserveResponse(val) { 103 | if (!this.options.preserveResponses) return; 104 | const resp = document.getElementById(`responses${this.rootDiv.id}`); 105 | if (!resp) return; 106 | const arr = JSON.parse(resp.dataset.responses); 107 | arr[this.index] = val; 108 | resp.dataset.responses = JSON.stringify(arr); 109 | printResponses(resp); 110 | } 111 | 112 | typeset(container) { 113 | if (typeof MathJax !== 'undefined') { 114 | const v = MathJax.version; 115 | if (v[0] === '2') { 116 | MathJax.Hub.Queue(['Typeset', MathJax.Hub]); 117 | } else { 118 | MathJax.typeset([container]); 119 | } 120 | } 121 | } 122 | } 123 | 124 | // Choose a random subset of an array. Can also be used to shuffle the array 125 | function getRandomSubarray(arr, size) { 126 | var shuffled = arr.slice(0), i = arr.length, temp, index; 127 | while (i--) { 128 | index = Math.floor((i + 1) * Math.random()); 129 | temp = shuffled[index]; 130 | shuffled[index] = shuffled[i]; 131 | shuffled[i] = temp; 132 | } 133 | return shuffled.slice(0, size); 134 | } 135 | 136 | function printResponses(responsesContainer) { 137 | var responses=JSON.parse(responsesContainer.dataset.responses); 138 | var stringResponses='IMPORTANT!To preserve this answer sequence for submission, when you have finalized your answers:
  1. Copy the text in this cell below "Answer String"
  2. Double click on the cell directly below the Answer String, labeled "Replace Me"
  3. Select the whole "Replace Me" text
  4. Paste in your answer string and press shift-Enter.
  5. Save the notebook using the save icon or File->Save Notebook menu item



  6. Answer String:
    '; 139 | console.log(responses); 140 | responses.forEach((response, index) => { 141 | if (response) { 142 | console.log(index + ': ' + response); 143 | stringResponses+= index + ': ' + response +"
    "; 144 | } 145 | }); 146 | responsesContainer.innerHTML=stringResponses; 147 | } 148 | -------------------------------------------------------------------------------- /jupyterquiz/js/multiple_choice.js: -------------------------------------------------------------------------------- 1 | /* Callback function to determine whether a selected multiple-choice 2 | button corresponded to a correct answer and to provide feedback 3 | based on the answer */ 4 | function check_mc() { 5 | var id = this.id.split('-')[0]; 6 | //var response = this.id.split('-')[1]; 7 | //console.log(response); 8 | //console.log("In check_mc(), id="+id); 9 | //console.log(event.srcElement.id) 10 | //console.log(event.srcElement.dataset.correct) 11 | //console.log(event.srcElement.dataset.feedback) 12 | 13 | var label = event.srcElement; 14 | //console.log(label, label.nodeName); 15 | var depth = 0; 16 | while ((label.nodeName != "LABEL") && (depth < 20)) { 17 | label = label.parentElement; 18 | console.log(depth, label); 19 | depth++; 20 | } 21 | 22 | 23 | 24 | var answers = label.parentElement.children; 25 | //console.log(answers); 26 | 27 | // Split behavior based on multiple choice vs many choice: 28 | var fb = document.getElementById("fb" + id); 29 | 30 | 31 | 32 | /* Multiple choice (1 answer). Allow for 0 correct 33 | answers as an edge case */ 34 | if (fb.dataset.numcorrect <= 1) { 35 | // What follows is for the saved responses stuff 36 | var outerContainer = fb.parentElement.parentElement; 37 | var responsesContainer = document.getElementById("responses" + outerContainer.id); 38 | if (responsesContainer) { 39 | //console.log(responsesContainer); 40 | var response = label.firstChild.innerText; 41 | if (label.querySelector(".QuizCode")){ 42 | response+= label.querySelector(".QuizCode").firstChild.innerText; 43 | } 44 | console.log(response); 45 | //console.log(document.getElementById("quizWrap"+id)); 46 | var qnum = document.getElementById("quizWrap"+id).dataset.qnum; 47 | console.log("Question " + qnum); 48 | //console.log(id, ", got numcorrect=",fb.dataset.numcorrect); 49 | var responses=JSON.parse(responsesContainer.dataset.responses); 50 | console.log(responses); 51 | responses[qnum]= response; 52 | responsesContainer.setAttribute('data-responses', JSON.stringify(responses)); 53 | printResponses(responsesContainer); 54 | } 55 | // End code to preserve responses 56 | 57 | for (var i = 0; i < answers.length; i++) { 58 | var child = answers[i]; 59 | //console.log(child); 60 | child.className = "MCButton"; 61 | } 62 | 63 | 64 | 65 | if (label.dataset.correct == "true") { 66 | // console.log("Correct action"); 67 | if ("feedback" in label.dataset) { 68 | fb.innerHTML = jaxify(label.dataset.feedback); 69 | } else { 70 | fb.innerHTML = "Correct!"; 71 | } 72 | label.classList.add("correctButton"); 73 | 74 | fb.className = "Feedback"; 75 | fb.classList.add("correct"); 76 | 77 | } else { 78 | if ("feedback" in label.dataset) { 79 | fb.innerHTML = jaxify(label.dataset.feedback); 80 | } else { 81 | fb.innerHTML = "Incorrect -- try again."; 82 | } 83 | //console.log("Error action"); 84 | label.classList.add("incorrectButton"); 85 | fb.className = "Feedback"; 86 | fb.classList.add("incorrect"); 87 | } 88 | } 89 | else { /* Many choice (more than 1 correct answer) */ 90 | var reset = false; 91 | var feedback; 92 | if (label.dataset.correct == "true") { 93 | if ("feedback" in label.dataset) { 94 | feedback = jaxify(label.dataset.feedback); 95 | } else { 96 | feedback = "Correct!"; 97 | } 98 | if (label.dataset.answered <= 0) { 99 | if (fb.dataset.answeredcorrect < 0) { 100 | fb.dataset.answeredcorrect = 1; 101 | reset = true; 102 | } else { 103 | fb.dataset.answeredcorrect++; 104 | } 105 | if (reset) { 106 | for (var i = 0; i < answers.length; i++) { 107 | var child = answers[i]; 108 | child.className = "MCButton"; 109 | child.dataset.answered = 0; 110 | } 111 | } 112 | label.classList.add("correctButton"); 113 | label.dataset.answered = 1; 114 | fb.className = "Feedback"; 115 | fb.classList.add("correct"); 116 | 117 | } 118 | } else { 119 | if ("feedback" in label.dataset) { 120 | feedback = jaxify(label.dataset.feedback); 121 | } else { 122 | feedback = "Incorrect -- try again."; 123 | } 124 | if (fb.dataset.answeredcorrect > 0) { 125 | fb.dataset.answeredcorrect = -1; 126 | reset = true; 127 | } else { 128 | fb.dataset.answeredcorrect--; 129 | } 130 | 131 | if (reset) { 132 | for (var i = 0; i < answers.length; i++) { 133 | var child = answers[i]; 134 | child.className = "MCButton"; 135 | child.dataset.answered = 0; 136 | } 137 | } 138 | label.classList.add("incorrectButton"); 139 | fb.className = "Feedback"; 140 | fb.classList.add("incorrect"); 141 | } 142 | // What follows is for the saved responses stuff 143 | var outerContainer = fb.parentElement.parentElement; 144 | var responsesContainer = document.getElementById("responses" + outerContainer.id); 145 | if (responsesContainer) { 146 | //console.log(responsesContainer); 147 | var response = label.firstChild.innerText; 148 | if (label.querySelector(".QuizCode")){ 149 | response+= label.querySelector(".QuizCode").firstChild.innerText; 150 | } 151 | console.log(response); 152 | //console.log(document.getElementById("quizWrap"+id)); 153 | var qnum = document.getElementById("quizWrap"+id).dataset.qnum; 154 | console.log("Question " + qnum); 155 | //console.log(id, ", got numcorrect=",fb.dataset.numcorrect); 156 | var responses=JSON.parse(responsesContainer.dataset.responses); 157 | if (label.dataset.correct == "true") { 158 | if (typeof(responses[qnum]) == "object"){ 159 | if (!responses[qnum].includes(response)) 160 | responses[qnum].push(response); 161 | } else{ 162 | responses[qnum]= [ response ]; 163 | } 164 | } else { 165 | responses[qnum]= response; 166 | } 167 | console.log(responses); 168 | responsesContainer.setAttribute('data-responses', JSON.stringify(responses)); 169 | printResponses(responsesContainer); 170 | } 171 | // End save responses stuff 172 | 173 | 174 | 175 | var numcorrect = fb.dataset.numcorrect; 176 | var answeredcorrect = fb.dataset.answeredcorrect; 177 | if (answeredcorrect >= 0) { 178 | fb.innerHTML = feedback + " [" + answeredcorrect + "/" + numcorrect + "]"; 179 | } else { 180 | fb.innerHTML = feedback + " [" + 0 + "/" + numcorrect + "]"; 181 | } 182 | 183 | 184 | } 185 | 186 | if (typeof MathJax != 'undefined') { 187 | var version = MathJax.version; 188 | console.log('MathJax version', version); 189 | if (version[0] == "2") { 190 | MathJax.Hub.Queue(["Typeset", MathJax.Hub]); 191 | } else if (version[0] == "3") { 192 | MathJax.typeset([fb]); 193 | } 194 | } else { 195 | console.log('MathJax not detected'); 196 | } 197 | 198 | } 199 | 200 | 201 | /* Function to produce the HTML buttons for a multiple choice/ 202 | many choice question and to update the CSS tags based on 203 | the question type */ 204 | function make_mc(qa, shuffle_answers, outerqDiv, qDiv, aDiv, id) { 205 | 206 | var shuffled; 207 | if (shuffle_answers == true) { 208 | //console.log(shuffle_answers+" read as true"); 209 | shuffled = getRandomSubarray(qa.answers, qa.answers.length); 210 | } else { 211 | //console.log(shuffle_answers+" read as false"); 212 | shuffled = qa.answers; 213 | } 214 | 215 | 216 | var num_correct = 0; 217 | 218 | shuffled.forEach((item, index, ans_array) => { 219 | //console.log(answer); 220 | 221 | // Make input element 222 | var inp = document.createElement("input"); 223 | inp.type = "radio"; 224 | inp.id = "quizo" + id + index; 225 | inp.style = "display:none;"; 226 | aDiv.append(inp); 227 | 228 | //Make label for input element 229 | var lab = document.createElement("label"); 230 | lab.className = "MCButton"; 231 | lab.id = id + '-' + index; 232 | lab.onclick = check_mc; 233 | var aSpan = document.createElement('span'); 234 | aSpan.classsName = ""; 235 | //qDiv.id="quizQn"+id+index; 236 | if ("answer" in item) { 237 | aSpan.innerHTML = jaxify(item.answer); 238 | //aSpan.innerHTML=item.answer; 239 | } 240 | lab.append(aSpan); 241 | 242 | // Create div for code inside question 243 | var codeSpan; 244 | if ("code" in item) { 245 | codeSpan = document.createElement('span'); 246 | codeSpan.id = "code" + id + index; 247 | codeSpan.className = "QuizCode"; 248 | var codePre = document.createElement('pre'); 249 | codeSpan.append(codePre); 250 | var codeCode = document.createElement('code'); 251 | codePre.append(codeCode); 252 | codeCode.innerHTML = item.code; 253 | lab.append(codeSpan); 254 | //console.log(codeSpan); 255 | } 256 | 257 | //lab.textContent=item.answer; 258 | 259 | // Set the data attributes for the answer 260 | lab.setAttribute('data-correct', item.correct); 261 | if (item.correct) { 262 | num_correct++; 263 | } 264 | if ("feedback" in item) { 265 | lab.setAttribute('data-feedback', item.feedback); 266 | } 267 | lab.setAttribute('data-answered', 0); 268 | 269 | aDiv.append(lab); 270 | 271 | }); 272 | 273 | if (num_correct > 1) { 274 | outerqDiv.className = "ManyChoiceQn"; 275 | } else { 276 | outerqDiv.className = "MultipleChoiceQn"; 277 | } 278 | 279 | return num_correct; 280 | 281 | } 282 | // Object-oriented wrapper for MC/MANY choice 283 | class MCQuestion extends Question { 284 | constructor(qa, id, idx, opts, rootDiv) { super(qa, id, idx, opts, rootDiv); } 285 | render() { 286 | //console.log("options.shuffleAnswers " + this.options.shuffleAnswers); 287 | const numCorrect = make_mc( 288 | this.qa, 289 | this.options.shuffleAnswers, 290 | this.outerqDiv, 291 | this.qDiv, 292 | this.aDiv, 293 | this.id 294 | ); 295 | if ('answer_cols' in this.qa) { 296 | this.aDiv.style.gridTemplateColumns = 297 | 'repeat(' + this.qa.answer_cols + ', 1fr)'; 298 | } 299 | this.fbDiv.dataset.numcorrect = numCorrect; 300 | this.wrapper.appendChild(this.fbDiv); 301 | } 302 | } 303 | Question.register('multiple_choice', MCQuestion); 304 | Question.register('many_choice', MCQuestion); 305 | -------------------------------------------------------------------------------- /jupyterquiz/js/numeric.js: -------------------------------------------------------------------------------- 1 | function check_numeric(ths, event) { 2 | 3 | if (event.keyCode === 13) { 4 | ths.blur(); 5 | 6 | var id = ths.id.split('-')[0]; 7 | 8 | var submission = ths.value; 9 | if (submission.indexOf('/') != -1) { 10 | var sub_parts = submission.split('/'); 11 | //console.log(sub_parts); 12 | submission = sub_parts[0] / sub_parts[1]; 13 | } 14 | //console.log("Reader entered", submission); 15 | 16 | if ("precision" in ths.dataset) { 17 | var precision = ths.dataset.precision; 18 | submission = Number(Number(submission).toPrecision(precision)); 19 | } 20 | 21 | 22 | //console.log("In check_numeric(), id="+id); 23 | //console.log(event.srcElement.id) 24 | //console.log(event.srcElement.dataset.feedback) 25 | 26 | var fb = document.getElementById("fb" + id); 27 | fb.style.display = "none"; 28 | fb.innerHTML = "Incorrect -- try again."; 29 | 30 | var answers = JSON.parse(ths.dataset.answers); 31 | //console.log(answers); 32 | 33 | var defaultFB = "Incorrect. Try again."; 34 | var correct; 35 | var done = false; 36 | answers.every(answer => { 37 | //console.log(answer.type); 38 | 39 | correct = false; 40 | // if (answer.type=="value"){ 41 | if ('value' in answer) { 42 | var value; 43 | if ("precision" in ths.dataset) { 44 | value = answer.value.toPrecision(ths.dataset.precision); 45 | } else { 46 | value = answer.value; 47 | } 48 | if (submission == value) { 49 | if ("feedback" in answer) { 50 | fb.innerHTML = jaxify(answer.feedback); 51 | } else { 52 | fb.innerHTML = jaxify("Correct"); 53 | } 54 | correct = answer.correct; 55 | //console.log(answer.correct); 56 | done = true; 57 | } 58 | 59 | // } else if (answer.type=="range") { 60 | } else if ('range' in answer) { 61 | console.log(answer.range); 62 | console.log(submission, submission >=answer.range[0], submission < answer.range[1]) 63 | if ((submission >= answer.range[0]) && (submission < answer.range[1])) { 64 | fb.innerHTML = jaxify(answer.feedback); 65 | correct = answer.correct; 66 | console.log(answer.correct); 67 | done = true; 68 | } 69 | } else if (answer.type == "default") { 70 | if ("feedback" in answer) { 71 | defaultFB = answer.feedback; 72 | } 73 | } 74 | if (done) { 75 | return false; // Break out of loop if this has been marked correct 76 | } else { 77 | return true; // Keep looking for case that includes this as a correct answer 78 | } 79 | }); 80 | console.log("done:", done); 81 | 82 | if ((!done) && (defaultFB != "")) { 83 | fb.innerHTML = jaxify(defaultFB); 84 | //console.log("Default feedback", defaultFB); 85 | } 86 | 87 | fb.style.display = "block"; 88 | if (correct) { 89 | ths.className = "Input-text"; 90 | ths.classList.add("correctButton"); 91 | fb.className = "Feedback"; 92 | fb.classList.add("correct"); 93 | } else { 94 | ths.className = "Input-text"; 95 | ths.classList.add("incorrectButton"); 96 | fb.className = "Feedback"; 97 | fb.classList.add("incorrect"); 98 | } 99 | 100 | // What follows is for the saved responses stuff 101 | var outerContainer = fb.parentElement.parentElement; 102 | var responsesContainer = document.getElementById("responses" + outerContainer.id); 103 | if (responsesContainer) { 104 | console.log(submission); 105 | var qnum = document.getElementById("quizWrap"+id).dataset.qnum; 106 | //console.log("Question " + qnum); 107 | //console.log(id, ", got numcorrect=",fb.dataset.numcorrect); 108 | var responses=JSON.parse(responsesContainer.dataset.responses); 109 | console.log(responses); 110 | if (submission == ths.value){ 111 | responses[qnum]= submission; 112 | } else { 113 | responses[qnum]= ths.value + "(" + submission +")"; 114 | } 115 | responsesContainer.setAttribute('data-responses', JSON.stringify(responses)); 116 | printResponses(responsesContainer); 117 | } 118 | // End code to preserve responses 119 | 120 | if (typeof MathJax != 'undefined') { 121 | var version = MathJax.version; 122 | console.log('MathJax version', version); 123 | if (version[0] == "2") { 124 | MathJax.Hub.Queue(["Typeset", MathJax.Hub]); 125 | } else if (version[0] == "3") { 126 | MathJax.typeset([fb]); 127 | } 128 | } else { 129 | console.log('MathJax not detected'); 130 | } 131 | // After correct answer, if next JupyterQuiz question exists and has a text input, scroll by current question height 132 | if (correct) { 133 | // find the current question wrapper 134 | var wrapper = ths.closest('.Quiz'); 135 | if (wrapper) { 136 | var nextWrapper = wrapper.nextElementSibling; 137 | if (nextWrapper && nextWrapper.classList.contains('Quiz')) { 138 | var nextInput = nextWrapper.querySelector('input.Input-text'); 139 | if (nextInput) { 140 | var height = wrapper.getBoundingClientRect().height; 141 | console.log(height); 142 | nextInput.focus(); 143 | } 144 | } 145 | } 146 | } 147 | return false; 148 | } 149 | 150 | } 151 | // Object-oriented wrapper for numeric questions 152 | class NumericQuestion extends Question { 153 | constructor(qa, id, idx, opts, rootDiv) { 154 | super(qa, id, idx, opts, rootDiv); 155 | } 156 | render() { 157 | make_numeric(this.qa, this.outerqDiv, this.qDiv, this.aDiv, this.id); 158 | this.wrapper.appendChild(this.fbDiv); 159 | } 160 | } 161 | Question.register('numeric', NumericQuestion); 162 | 163 | function isValid(el, charC) { 164 | //console.log("Input char: ", charC); 165 | if (charC == 46) { 166 | if (el.value.indexOf('.') === -1) { 167 | return true; 168 | } else if (el.value.indexOf('/') != -1) { 169 | var parts = el.value.split('/'); 170 | if (parts[1].indexOf('.') === -1) { 171 | return true; 172 | } 173 | } 174 | else { 175 | return false; 176 | } 177 | } else if (charC == 47) { 178 | if (el.value.indexOf('/') === -1) { 179 | if ((el.value != "") && (el.value != ".")) { 180 | return true; 181 | } else { 182 | return false; 183 | } 184 | } else { 185 | return false; 186 | } 187 | } else if (charC == 45) { 188 | var edex = el.value.indexOf('e'); 189 | if (edex == -1) { 190 | edex = el.value.indexOf('E'); 191 | } 192 | 193 | if (el.value == "") { 194 | return true; 195 | } else if (edex == (el.value.length - 1)) { // If just after e or E 196 | return true; 197 | } else { 198 | return false; 199 | } 200 | } else if (charC == 101) { // "e" 201 | if ((el.value.indexOf('e') === -1) && (el.value.indexOf('E') === -1) && (el.value.indexOf('/') == -1)) { 202 | // Prev symbol must be digit or decimal point: 203 | if (el.value.slice(-1).search(/\d/) >= 0) { 204 | return true; 205 | } else if (el.value.slice(-1).search(/\./) >= 0) { 206 | return true; 207 | } else { 208 | return false; 209 | } 210 | } else { 211 | return false; 212 | } 213 | } else { 214 | if (charC > 31 && (charC < 48 || charC > 57)) 215 | return false; 216 | } 217 | return true; 218 | } 219 | 220 | function numeric_keypress(evnt) { 221 | var charC = (evnt.which) ? evnt.which : evnt.keyCode; 222 | 223 | if (charC == 13) { 224 | check_numeric(this, evnt); 225 | } else { 226 | return isValid(this, charC); 227 | } 228 | } 229 | 230 | 231 | 232 | 233 | 234 | function make_numeric(qa, outerqDiv, qDiv, aDiv, id) { 235 | 236 | 237 | 238 | //console.log(answer); 239 | 240 | 241 | outerqDiv.className = "NumericQn"; 242 | aDiv.style.display = 'block'; 243 | 244 | var lab = document.createElement("label"); 245 | lab.className = "InpLabel"; 246 | lab.innerHTML = "Type numeric answer here:"; 247 | aDiv.append(lab); 248 | 249 | var inp = document.createElement("input"); 250 | inp.type = "text"; 251 | //inp.id="input-"+id; 252 | inp.id = id + "-0"; 253 | inp.className = "Input-text"; 254 | inp.setAttribute('data-answers', JSON.stringify(qa.answers)); 255 | if ("precision" in qa) { 256 | inp.setAttribute('data-precision', qa.precision); 257 | } 258 | aDiv.append(inp); 259 | //console.log(inp); 260 | 261 | //inp.addEventListener("keypress", check_numeric); 262 | //inp.addEventListener("keypress", numeric_keypress); 263 | /* 264 | inp.addEventListener("keypress", function(event) { 265 | return numeric_keypress(this, event); 266 | } 267 | ); 268 | */ 269 | //inp.onkeypress="return numeric_keypress(this, event)"; 270 | inp.onkeypress = numeric_keypress; 271 | inp.onpaste = event => false; 272 | 273 | inp.addEventListener("focus", function (event) { 274 | this.value = ""; 275 | return false; 276 | } 277 | ); 278 | 279 | 280 | } 281 | -------------------------------------------------------------------------------- /jupyterquiz/js/show_questions.js: -------------------------------------------------------------------------------- 1 | // Override show_questions to use object-oriented Question API 2 | function show_questions(json, container) { 3 | // Accept container element or element ID 4 | if (typeof container === 'string') { 5 | container = document.getElementById(container); 6 | } 7 | if (!container) { 8 | console.error('show_questions: invalid container', container); 9 | return; 10 | } 11 | 12 | const shuffleQuestions = container.dataset.shufflequestions === 'True'; 13 | const shuffleAnswers = container.dataset.shuffleanswers === 'True'; 14 | const preserveResponses = container.dataset.preserveresponses === 'true'; 15 | const maxWidth = parseInt(container.dataset.maxwidth, 10) || 0; 16 | let numQuestions = parseInt(container.dataset.numquestions, 10) || json.length; 17 | if (numQuestions > json.length) numQuestions = json.length; 18 | 19 | let questions = json; 20 | if (shuffleQuestions || numQuestions < json.length) { 21 | questions = getRandomSubarray(json, numQuestions); 22 | } 23 | 24 | questions.forEach((qa, index) => { 25 | const id = makeid(8); 26 | const options = { 27 | shuffleAnswers: shuffleAnswers, 28 | preserveResponses: preserveResponses, 29 | maxWidth: maxWidth 30 | }; 31 | Question.create(qa, id, index, options, container); 32 | }); 33 | 34 | if (preserveResponses) { 35 | const respDiv = document.createElement('div'); 36 | respDiv.id = 'responses' + container.id; 37 | respDiv.className = 'JCResponses'; 38 | respDiv.dataset.responses = JSON.stringify([]); 39 | respDiv.innerHTML = 'Select your answers and then follow the directions that will appear here.'; 40 | container.appendChild(respDiv); 41 | } 42 | 43 | // Trigger MathJax typesetting if available 44 | if (typeof MathJax != 'undefined') { 45 | console.log("MathJax version", MathJax.version); 46 | var version = MathJax.version; 47 | setTimeout(function(){ 48 | var version = MathJax.version; 49 | console.log('After sleep, MathJax version', version); 50 | if (version[0] == "2") { 51 | MathJax.Hub.Queue(["Typeset", MathJax.Hub]); 52 | } else if (version[0] == "3") { 53 | if (MathJax.hasOwnProperty('typeset') ) { 54 | MathJax.typeset([container]); 55 | } else { 56 | console.log('WARNING: Trying to force load MathJax 3'); 57 | window.MathJax = { 58 | tex: { 59 | inlineMath: [['$', '$'], ['\\(', '\\)']] 60 | }, 61 | svg: { 62 | fontCache: 'global' 63 | } 64 | }; 65 | 66 | (function () { 67 | var script = document.createElement('script'); 68 | script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js'; 69 | script.async = true; 70 | document.head.appendChild(script); 71 | })(); 72 | } 73 | } 74 | }, 500); 75 | if (typeof version == 'undefined') { 76 | } else 77 | { 78 | if (version[0] == "2") { 79 | MathJax.Hub.Queue(["Typeset", MathJax.Hub]); 80 | } else if (version[0] == "3") { 81 | if (MathJax.hasOwnProperty('typeset') ) { 82 | MathJax.typeset([container]); 83 | } else { 84 | console.log('WARNING: Trying to force load MathJax 3'); 85 | window.MathJax = { 86 | tex: { 87 | inlineMath: [['$', '$'], ['\\(', '\\)']] 88 | }, 89 | svg: { 90 | fontCache: 'global' 91 | } 92 | }; 93 | 94 | (function () { 95 | var script = document.createElement('script'); 96 | script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js'; 97 | script.async = true; 98 | document.head.appendChild(script); 99 | })(); 100 | } 101 | } else { 102 | console.log("MathJax not found"); 103 | } 104 | } 105 | } 106 | // if (typeof MathJax !== 'undefined') { 107 | // const v = MathJax.version; 108 | // if (v[0] === '2') { 109 | // MathJax.Hub.Queue(['Typeset', MathJax.Hub]); 110 | // } else if (v[0] === '3') { 111 | // MathJax.typeset([container]); 112 | // } 113 | // } 114 | 115 | // Prevent link clicks from bubbling up 116 | Array.from(container.getElementsByClassName('Link')).forEach(link => { 117 | link.addEventListener('click', e => e.stopPropagation()); 118 | }); 119 | } 120 | -------------------------------------------------------------------------------- /jupyterquiz/js/static_suffix.js.tpl: -------------------------------------------------------------------------------- 1 | /* 2 | * Handle asynchrony issues when re-running quizzes in Jupyter notebooks. 3 | * Ensures show_questions is called after the container div is in the DOM. 4 | */ 5 | function try_show() { 6 | if (document.getElementById("${div_id}")) { 7 | show_questions(questions${div_id}, ${div_id}); 8 | } else { 9 | setTimeout(try_show, 200); 10 | } 11 | }; 12 | // Invoke immediately 13 | { 14 | try_show(); 15 | } -------------------------------------------------------------------------------- /jupyterquiz/js/string.js: -------------------------------------------------------------------------------- 1 | function levenshteinDistance(a, b) { 2 | if (a.length === 0) return b.length; 3 | if (b.length === 0) return a.length; 4 | 5 | const matrix = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(null)); 6 | 7 | for (let i = 0; i <= a.length; i++) { 8 | matrix[0][i] = i; 9 | } 10 | 11 | for (let j = 0; j <= b.length; j++) { 12 | matrix[j][0] = j; 13 | } 14 | 15 | for (let j = 1; j <= b.length; j++) { 16 | for (let i = 1; i <= a.length; i++) { 17 | const cost = a[i - 1] === b[j - 1] ? 0 : 1; 18 | matrix[j][i] = Math.min( 19 | matrix[j - 1][i] + 1, // Deletion 20 | matrix[j][i - 1] + 1, // Insertion 21 | matrix[j - 1][i - 1] + cost // Substitution 22 | ); 23 | } 24 | } 25 | return matrix[b.length][a.length]; 26 | } 27 | // Object-oriented wrapper for string input questions 28 | class StringQuestion extends Question { 29 | constructor(qa, id, idx, opts, rootDiv) { 30 | super(qa, id, idx, opts, rootDiv); 31 | } 32 | render() { 33 | make_string(this.qa, this.outerqDiv, this.qDiv, this.aDiv, this.id); 34 | this.wrapper.appendChild(this.fbDiv); 35 | } 36 | } 37 | Question.register('string', StringQuestion); 38 | 39 | function check_string(ths, event) { 40 | if (event.keyCode === 13) { 41 | ths.blur(); 42 | 43 | var id = ths.id.split('-')[0]; 44 | var submission = ths.value.trim(); 45 | var fb = document.getElementById("fb" + id); 46 | fb.style.display = "none"; 47 | fb.innerHTML = "Incorrect -- try again."; 48 | 49 | var answers = JSON.parse(ths.dataset.answers); 50 | var defaultFB = "Incorrect. Try again."; 51 | var correct; 52 | var done = false; 53 | 54 | // Handle default answer pattern: filter out and capture default feedback 55 | var filteredAnswers = []; 56 | answers.forEach(answer => { 57 | if (answer.type === "default") { 58 | defaultFB = answer.feedback; 59 | } else { 60 | filteredAnswers.push(answer); 61 | } 62 | }); 63 | answers = filteredAnswers; 64 | 65 | answers.every(answer => { 66 | correct = false; 67 | 68 | let match = false; 69 | if (answer.match_case) { 70 | match = submission === answer.answer; 71 | } else { 72 | match = submission.toLowerCase() === answer.answer.toLowerCase(); 73 | } 74 | console.log(submission); 75 | console.log(answer.answer); 76 | console.log(match); 77 | 78 | if (match) { 79 | if ("feedback" in answer) { 80 | fb.innerHTML = jaxify(answer.feedback); 81 | } else { 82 | fb.innerHTML = jaxify("Correct"); 83 | } 84 | correct = answer.correct; 85 | done = true; 86 | } else if (answer.fuzzy_threshold) { 87 | var max_length = Math.max(submission.length, answer.answer.length); 88 | var ratio; 89 | if (answer.match_case) { 90 | ratio = 1- (levenshteinDistance(submission, answer.answer) / max_length); 91 | } else { 92 | ratio = 1- (levenshteinDistance(submission.toLowerCase(), 93 | answer.answer.toLowerCase()) / max_length); 94 | } 95 | if (ratio >= answer.fuzzy_threshold) { 96 | if ("feedback" in answer) { 97 | fb.innerHTML = jaxify("(Fuzzy) " + answer.feedback); 98 | } else { 99 | fb.innerHTML = jaxify("Correct"); 100 | } 101 | correct = answer.correct; 102 | done = true; 103 | } 104 | 105 | } 106 | 107 | if (done) { 108 | return false; 109 | } else { 110 | return true; 111 | } 112 | }); 113 | 114 | if ((!done) && (defaultFB != "")) { 115 | fb.innerHTML = jaxify(defaultFB); 116 | } 117 | 118 | fb.style.display = "block"; 119 | if (correct) { 120 | ths.className = "Input-text"; 121 | ths.classList.add("correctButton"); 122 | fb.className = "Feedback"; 123 | fb.classList.add("correct"); 124 | } else { 125 | ths.className = "Input-text"; 126 | ths.classList.add("incorrectButton"); 127 | fb.className = "Feedback"; 128 | fb.classList.add("incorrect"); 129 | } 130 | 131 | var outerContainer = fb.parentElement.parentElement; 132 | var responsesContainer = document.getElementById("responses" + outerContainer.id); 133 | if (responsesContainer) { 134 | var qnum = document.getElementById("quizWrap" + id).dataset.qnum; 135 | var responses = JSON.parse(responsesContainer.dataset.responses); 136 | responses[qnum] = submission; 137 | responsesContainer.setAttribute('data-responses', JSON.stringify(responses)); 138 | printResponses(responsesContainer); 139 | } 140 | 141 | if (typeof MathJax != 'undefined') { 142 | var version = MathJax.version; 143 | if (version[0] == "2") { 144 | MathJax.Hub.Queue(["Typeset", MathJax.Hub]); 145 | } else if (version[0] == "3") { 146 | MathJax.typeset([fb]); 147 | } 148 | } else { 149 | console.log('MathJax not detected'); 150 | } 151 | // After correct answer, if next JupyterQuiz question exists and has a text input, scroll by current question height 152 | if (correct) { 153 | var wrapper = ths.closest('.Quiz'); 154 | if (wrapper) { 155 | var nextWrapper = wrapper.nextElementSibling; 156 | if (nextWrapper && nextWrapper.classList.contains('Quiz')) { 157 | var nextInput = nextWrapper.querySelector('input.Input-text'); 158 | if (nextInput) { 159 | var height = wrapper.getBoundingClientRect().height; 160 | nextInput.focus(); 161 | } 162 | } 163 | } 164 | } 165 | return false; 166 | } 167 | } 168 | 169 | function string_keypress(evnt) { 170 | var charC = (evnt.which) ? evnt.which : evnt.keyCode; 171 | 172 | if (charC == 13) { 173 | check_string(this, evnt); 174 | } 175 | } 176 | 177 | 178 | function make_string(qa, outerqDiv, qDiv, aDiv, id) { 179 | outerqDiv.className = "StringQn"; 180 | aDiv.style.display = 'block'; 181 | 182 | var lab = document.createElement("label"); 183 | lab.className = "InpLabel"; 184 | lab.innerHTML = "Type your answer here:"; 185 | aDiv.append(lab); 186 | 187 | var inp = document.createElement("input"); 188 | inp.type = "text"; 189 | inp.id = id + "-0"; 190 | inp.className = "Input-text"; 191 | inp.setAttribute('data-answers', JSON.stringify(qa.answers)); 192 | // Apply optional input width (approx. number of characters, in em units) 193 | if (qa.input_width != null) { 194 | inp.style['min-width'] = qa.input_width + 'em'; 195 | } 196 | aDiv.append(inp); 197 | 198 | inp.onkeypress = string_keypress; 199 | inp.onpaste = event => false; 200 | 201 | inp.addEventListener("focus", function (event) { 202 | this.value = ""; 203 | return false; 204 | }); 205 | } 206 | -------------------------------------------------------------------------------- /jupyterquiz/styles.css: -------------------------------------------------------------------------------- 1 | .Quiz { 2 | max-width: 600px; 3 | margin-top: 15px; 4 | margin-left: auto; 5 | margin-right: auto; 6 | /* margin-bottom: 15px;*/ 7 | /* padding-bottom: 4px;*/ 8 | padding-top: 4px; 9 | line-height: 1.1; 10 | font-size: 16pt; 11 | border-radius: inherit; 12 | } 13 | 14 | .QuizCode { 15 | font-size: 14pt; 16 | margin-top: 10px; 17 | margin-left: 20px; 18 | margin-right: 20px; 19 | } 20 | 21 | .QuizCode>pre { 22 | padding: 4px; 23 | } 24 | 25 | .Quiz code { 26 | background-color: lightgray; 27 | color: black; 28 | } 29 | 30 | .Quiz .QuizCode code { 31 | background-color: inherit; 32 | color: inherit; 33 | } 34 | 35 | 36 | .Quiz .MCButton code { 37 | background-color: inherit; 38 | color: inherit; 39 | } 40 | 41 | .MCButton .QuizCode { 42 | text-align: left; 43 | } 44 | 45 | 46 | 47 | 48 | .Answer { 49 | border-radius: inherit; 50 | display: grid; 51 | grid-gap: 10px; 52 | grid-template-columns: 1fr 1fr; 53 | margin: 10px 0; 54 | } 55 | 56 | @media only screen and (max-width:480px) { 57 | .Answer { 58 | grid-template-columns: 1fr; 59 | } 60 | 61 | } 62 | 63 | .Feedback { 64 | font-size: 16pt; 65 | text-align: center; 66 | /* min-height: 2em;*/ 67 | } 68 | 69 | .Input { 70 | align: left; 71 | font-size: 20pt; 72 | } 73 | 74 | .Input-text { 75 | display: block; 76 | margin: 10px; 77 | color: inherit; 78 | width: unset; 79 | min-width: 140px; 80 | max-width: 93%; 81 | field-sizing: content; 82 | background-color: var(--jq-numeric-input-bg); 83 | color: var(--jq-text-color); 84 | padding: 5px; 85 | padding-left: 10px; 86 | font-family: inherit; 87 | font-size: 20px; 88 | font-weight: inherit; 89 | line-height: 20pt; 90 | border: none; 91 | border-radius: 0.2rem; 92 | transition: box-shadow 0.1s); 93 | } 94 | 95 | .Input-text:focus { 96 | /*outline: none;*/ 97 | background-color: var(--jq-numeric-input-bg); 98 | box-shadow: 0.6rem 0.8rem 1.4rem -0.5rem var(--jq-numeric-input-shadow); 99 | } 100 | 101 | .MCButton { 102 | background: var(--jq-mc-button-bg); 103 | border: 1px solid var(--jq-mc-button-border); 104 | border-radius: inherit; 105 | color: #333333; 106 | padding: 10px; 107 | font-size: 16px; 108 | cursor: pointer; 109 | text-align: center; 110 | display: flex; 111 | align-items: center; 112 | justify-content: center; 113 | } 114 | 115 | .MCButton p { 116 | color: inherit; 117 | } 118 | 119 | .MultipleChoiceQn { 120 | padding: 10px; 121 | background: var(--jq-multiple-choice-bg); 122 | color: var(--jq-text-color); 123 | border-radius: inherit; 124 | } 125 | 126 | .ManyChoiceQn { 127 | padding: 10px; 128 | background: var(--jq-many-choice-bg); 129 | color: var(--jq-text-color); 130 | border-radius: inherit; 131 | } 132 | 133 | .NumericQn { 134 | background: var(--jq-numeric-bg); 135 | border-radius: inherit; 136 | color: var(--jq-text-color); 137 | padding: 10px; 138 | } 139 | 140 | .NumericQn p { 141 | color: inherit; 142 | } 143 | 144 | .StringQn { 145 | background: var(--jq-string-bg); 146 | border-radius: inherit; 147 | color: var(--jq-text-color); 148 | padding: 10px; 149 | } 150 | 151 | .StringQn p { 152 | color: inherit; 153 | } 154 | 155 | 156 | .InpLabel { 157 | color: var(--jq-numeric-input-label); 158 | float: left; 159 | font-size: 15pt; 160 | line-height: 34px; 161 | margin-right: 10px; 162 | } 163 | 164 | .incorrect { 165 | color: var(--jq-incorrect-color); 166 | } 167 | 168 | .correct { 169 | color: var(--jq-correct-color); 170 | } 171 | 172 | .correctButton { 173 | /* 174 | background: var(--jq-correct-color); 175 | */ 176 | animation: correct-anim 0.6s ease; 177 | animation-fill-mode: forwards; 178 | box-shadow: inset 0 0 5px var(--jq-mc-button-inset-shadow); 179 | color: var(--jq-text-color); 180 | /*outline: none;*/ 181 | } 182 | 183 | .incorrectButton { 184 | animation: incorrect-anim 0.8s ease; 185 | animation-fill-mode: forwards; 186 | box-shadow: inset 0 0 5px var(--jq-mc-button-inset-shadow); 187 | color: var(--jq-text-color); 188 | /*outline: none;*/ 189 | } 190 | 191 | @keyframes incorrect-anim { 192 | 100% { 193 | background-color: var(--jq-incorrect-color); 194 | } 195 | } 196 | 197 | @keyframes correct-anim { 198 | 100% { 199 | background-color: var(--jq-correct-color); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /mve.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Minimum Viable Exmample\n", 8 | "\n", 9 | "1. Loads JupyterQuiz\n", 10 | "2. Sets up questions using LaTeX notation (I normally load these from an online file, but wanted this to be self-contained.)\n", 11 | "3. Displays the quiz\n", 12 | "\n", 13 | "Because the math is added by Javascript, it does not get typeset by Jupyter, and my code is designed to call `MathJax.typeset()` to typeset the math. However, in JupyterLab 4+, the MathJax variable does not have the `typeset()` method.\n", 14 | "\n", 15 | "$\\mbox{ }$" 16 | ] 17 | }, 18 | { 19 | "cell_type": "code", 20 | "execution_count": 1, 21 | "metadata": { 22 | "tags": [ 23 | "remove-input" 24 | ] 25 | }, 26 | "outputs": [], 27 | "source": [ 28 | "from jupyterquiz import display_quiz" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 2, 34 | "metadata": {}, 35 | "outputs": [], 36 | "source": [ 37 | "questions = \\\n", 38 | "[{'question': \"Which of these is the ratio of a circle's circumference to its diameter?\",\n", 39 | " 'type': 'multiple_choice',\n", 40 | " 'answers': [{'answer': 'pi', 'correct': True, 'feedback': 'Correct.'},\n", 41 | " {'answer': 'frac227',\n", 42 | " 'correct': False,\n", 43 | " 'feedback': 'frac227 is only an approximation to the true value.'},\n", 44 | " {'answer': '3',\n", 45 | " 'correct': False,\n", 46 | " 'feedback': 'This is a crude approximation to the true value.'},\n", 47 | " {'answer': 'tau',\n", 48 | " 'correct': False,\n", 49 | " 'feedback': \"True for the ratio of the circle's circumference to its radius, not diameter.\"}]},\n", 50 | " {'question': 'Enter the value of pi to 2 decimal places.',\n", 51 | " 'type': 'numeric',\n", 52 | " 'answers': [{'type': 'value',\n", 53 | " 'value': 3.14,\n", 54 | " 'correct': True,\n", 55 | " 'feedback': 'Correct.'},\n", 56 | " {'type': 'range',\n", 57 | " 'range': [3.142857, 3.142858],\n", 58 | " 'correct': True,\n", 59 | " 'feedback': 'True to 2 decimal places, but you know pi is not really 22/7, right?'},\n", 60 | " {'type': 'range',\n", 61 | " 'range': [-100000000, 0],\n", 62 | " 'correct': False,\n", 63 | " 'feedback': 'pi is the AREA of a circle of radius 1. Try again.'},\n", 64 | " {'type': 'default',\n", 65 | " 'feedback': 'pi is the area of a circle of radius 1. Try again.'}]}]\n" 66 | ] 67 | }, 68 | { 69 | "cell_type": "code", 70 | "execution_count": 3, 71 | "metadata": {}, 72 | "outputs": [ 73 | { 74 | "data": { 75 | "text/html": [ 76 | "
    " 253 | ], 254 | "text/plain": [ 255 | "" 256 | ] 257 | }, 258 | "metadata": {}, 259 | "output_type": "display_data" 260 | }, 261 | { 262 | "data": { 263 | "application/javascript": [ 264 | "var questionsHkGwvkyiZWoc=[{\"question\": \"Which of these is the ratio of a circle's circumference to its diameter?\", \"type\": \"multiple_choice\", \"answers\": [{\"answer\": \"pi\", \"correct\": true, \"feedback\": \"Correct.\"}, {\"answer\": \"frac227\", \"correct\": false, \"feedback\": \"frac227 is only an approximation to the true value.\"}, {\"answer\": \"3\", \"correct\": false, \"feedback\": \"This is a crude approximation to the true value.\"}, {\"answer\": \"tau\", \"correct\": false, \"feedback\": \"True for the ratio of the circle's circumference to its radius, not diameter.\"}]}, {\"question\": \"Enter the value of pi to 2 decimal places.\", \"type\": \"numeric\", \"answers\": [{\"type\": \"value\", \"value\": 3.14, \"correct\": true, \"feedback\": \"Correct.\"}, {\"type\": \"range\", \"range\": [3.142857, 3.142858], \"correct\": true, \"feedback\": \"True to 2 decimal places, but you know pi is not really 22/7, right?\"}, {\"type\": \"range\", \"range\": [-100000000, 0], \"correct\": false, \"feedback\": \"pi is the AREA of a circle of radius 1. Try again.\"}, {\"type\": \"default\", \"feedback\": \"pi is the area of a circle of radius 1. Try again.\"}]}];\n", 265 | " // Make a random ID\n", 266 | "function makeid(length) {\n", 267 | " var result = [];\n", 268 | " var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';\n", 269 | " var charactersLength = characters.length;\n", 270 | " for (var i = 0; i < length; i++) {\n", 271 | " result.push(characters.charAt(Math.floor(Math.random() * charactersLength)));\n", 272 | " }\n", 273 | " return result.join('');\n", 274 | "}\n", 275 | "\n", 276 | "// Choose a random subset of an array. Can also be used to shuffle the array\n", 277 | "function getRandomSubarray(arr, size) {\n", 278 | " var shuffled = arr.slice(0), i = arr.length, temp, index;\n", 279 | " while (i--) {\n", 280 | " index = Math.floor((i + 1) * Math.random());\n", 281 | " temp = shuffled[index];\n", 282 | " shuffled[index] = shuffled[i];\n", 283 | " shuffled[i] = temp;\n", 284 | " }\n", 285 | " return shuffled.slice(0, size);\n", 286 | "}\n", 287 | "\n", 288 | "function printResponses(responsesContainer) {\n", 289 | " var responses=JSON.parse(responsesContainer.dataset.responses);\n", 290 | " var stringResponses='IMPORTANT!To preserve this answer sequence for submission, when you have finalized your answers:
    1. Copy the text in this cell below \"Answer String\"
    2. Double click on the cell directly below the Answer String, labeled \"Replace Me\"
    3. Select the whole \"Replace Me\" text
    4. Paste in your answer string and press shift-Enter.
    5. Save the notebook using the save icon or File->Save Notebook menu item



    6. Answer String:
      ';\n", 291 | " console.log(responses);\n", 292 | " responses.forEach((response, index) => {\n", 293 | " if (response) {\n", 294 | " console.log(index + ': ' + response);\n", 295 | " stringResponses+= index + ': ' + response +\"
      \";\n", 296 | " }\n", 297 | " });\n", 298 | " responsesContainer.innerHTML=stringResponses;\n", 299 | "}\n", 300 | "function check_mc() {\n", 301 | " var id = this.id.split('-')[0];\n", 302 | " //var response = this.id.split('-')[1];\n", 303 | " //console.log(response);\n", 304 | " //console.log(\"In check_mc(), id=\"+id);\n", 305 | " //console.log(event.srcElement.id) \n", 306 | " //console.log(event.srcElement.dataset.correct) \n", 307 | " //console.log(event.srcElement.dataset.feedback)\n", 308 | "\n", 309 | " var label = event.srcElement;\n", 310 | " //console.log(label, label.nodeName);\n", 311 | " var depth = 0;\n", 312 | " while ((label.nodeName != \"LABEL\") && (depth < 20)) {\n", 313 | " label = label.parentElement;\n", 314 | " console.log(depth, label);\n", 315 | " depth++;\n", 316 | " }\n", 317 | "\n", 318 | "\n", 319 | "\n", 320 | " var answers = label.parentElement.children;\n", 321 | "\n", 322 | " //console.log(answers);\n", 323 | "\n", 324 | "\n", 325 | " // Split behavior based on multiple choice vs many choice:\n", 326 | " var fb = document.getElementById(\"fb\" + id);\n", 327 | "\n", 328 | "\n", 329 | "\n", 330 | "\n", 331 | " if (fb.dataset.numcorrect == 1) {\n", 332 | " // What follows is for the saved responses stuff\n", 333 | " var outerContainer = fb.parentElement.parentElement;\n", 334 | " var responsesContainer = document.getElementById(\"responses\" + outerContainer.id);\n", 335 | " if (responsesContainer) {\n", 336 | " //console.log(responsesContainer);\n", 337 | " var response = label.firstChild.innerText;\n", 338 | " if (label.querySelector(\".QuizCode\")){\n", 339 | " response+= label.querySelector(\".QuizCode\").firstChild.innerText;\n", 340 | " }\n", 341 | " console.log(response);\n", 342 | " //console.log(document.getElementById(\"quizWrap\"+id));\n", 343 | " var qnum = document.getElementById(\"quizWrap\"+id).dataset.qnum;\n", 344 | " console.log(\"Question \" + qnum);\n", 345 | " //console.log(id, \", got numcorrect=\",fb.dataset.numcorrect);\n", 346 | " var responses=JSON.parse(responsesContainer.dataset.responses);\n", 347 | " console.log(responses);\n", 348 | " responses[qnum]= response;\n", 349 | " responsesContainer.setAttribute('data-responses', JSON.stringify(responses));\n", 350 | " printResponses(responsesContainer);\n", 351 | " }\n", 352 | " // End code to preserve responses\n", 353 | " \n", 354 | " for (var i = 0; i < answers.length; i++) {\n", 355 | " var child = answers[i];\n", 356 | " //console.log(child);\n", 357 | " child.className = \"MCButton\";\n", 358 | " }\n", 359 | "\n", 360 | "\n", 361 | "\n", 362 | " if (label.dataset.correct == \"true\") {\n", 363 | " // console.log(\"Correct action\");\n", 364 | " if (\"feedback\" in label.dataset) {\n", 365 | " fb.textContent = jaxify(label.dataset.feedback);\n", 366 | " } else {\n", 367 | " fb.textContent = \"Correct!\";\n", 368 | " }\n", 369 | " label.classList.add(\"correctButton\");\n", 370 | "\n", 371 | " fb.className = \"Feedback\";\n", 372 | " fb.classList.add(\"correct\");\n", 373 | "\n", 374 | " } else {\n", 375 | " if (\"feedback\" in label.dataset) {\n", 376 | " fb.textContent = jaxify(label.dataset.feedback);\n", 377 | " } else {\n", 378 | " fb.textContent = \"Incorrect -- try again.\";\n", 379 | " }\n", 380 | " //console.log(\"Error action\");\n", 381 | " label.classList.add(\"incorrectButton\");\n", 382 | " fb.className = \"Feedback\";\n", 383 | " fb.classList.add(\"incorrect\");\n", 384 | " }\n", 385 | " }\n", 386 | " else {\n", 387 | " var reset = false;\n", 388 | " var feedback;\n", 389 | " if (label.dataset.correct == \"true\") {\n", 390 | " if (\"feedback\" in label.dataset) {\n", 391 | " feedback = jaxify(label.dataset.feedback);\n", 392 | " } else {\n", 393 | " feedback = \"Correct!\";\n", 394 | " }\n", 395 | " if (label.dataset.answered <= 0) {\n", 396 | " if (fb.dataset.answeredcorrect < 0) {\n", 397 | " fb.dataset.answeredcorrect = 1;\n", 398 | " reset = true;\n", 399 | " } else {\n", 400 | " fb.dataset.answeredcorrect++;\n", 401 | " }\n", 402 | " if (reset) {\n", 403 | " for (var i = 0; i < answers.length; i++) {\n", 404 | " var child = answers[i];\n", 405 | " child.className = \"MCButton\";\n", 406 | " child.dataset.answered = 0;\n", 407 | " }\n", 408 | " }\n", 409 | " label.classList.add(\"correctButton\");\n", 410 | " label.dataset.answered = 1;\n", 411 | " fb.className = \"Feedback\";\n", 412 | " fb.classList.add(\"correct\");\n", 413 | "\n", 414 | " }\n", 415 | " } else {\n", 416 | " if (\"feedback\" in label.dataset) {\n", 417 | " feedback = jaxify(label.dataset.feedback);\n", 418 | " } else {\n", 419 | " feedback = \"Incorrect -- try again.\";\n", 420 | " }\n", 421 | " if (fb.dataset.answeredcorrect > 0) {\n", 422 | " fb.dataset.answeredcorrect = -1;\n", 423 | " reset = true;\n", 424 | " } else {\n", 425 | " fb.dataset.answeredcorrect--;\n", 426 | " }\n", 427 | "\n", 428 | " if (reset) {\n", 429 | " for (var i = 0; i < answers.length; i++) {\n", 430 | " var child = answers[i];\n", 431 | " child.className = \"MCButton\";\n", 432 | " child.dataset.answered = 0;\n", 433 | " }\n", 434 | " }\n", 435 | " label.classList.add(\"incorrectButton\");\n", 436 | " fb.className = \"Feedback\";\n", 437 | " fb.classList.add(\"incorrect\");\n", 438 | " }\n", 439 | " // What follows is for the saved responses stuff\n", 440 | " var outerContainer = fb.parentElement.parentElement;\n", 441 | " var responsesContainer = document.getElementById(\"responses\" + outerContainer.id);\n", 442 | " if (responsesContainer) {\n", 443 | " //console.log(responsesContainer);\n", 444 | " var response = label.firstChild.innerText;\n", 445 | " if (label.querySelector(\".QuizCode\")){\n", 446 | " response+= label.querySelector(\".QuizCode\").firstChild.innerText;\n", 447 | " }\n", 448 | " console.log(response);\n", 449 | " //console.log(document.getElementById(\"quizWrap\"+id));\n", 450 | " var qnum = document.getElementById(\"quizWrap\"+id).dataset.qnum;\n", 451 | " console.log(\"Question \" + qnum);\n", 452 | " //console.log(id, \", got numcorrect=\",fb.dataset.numcorrect);\n", 453 | " var responses=JSON.parse(responsesContainer.dataset.responses);\n", 454 | " if (label.dataset.correct == \"true\") {\n", 455 | " if (typeof(responses[qnum]) == \"object\"){\n", 456 | " if (!responses[qnum].includes(response))\n", 457 | " responses[qnum].push(response);\n", 458 | " } else{\n", 459 | " responses[qnum]= [ response ];\n", 460 | " }\n", 461 | " } else {\n", 462 | " responses[qnum]= response;\n", 463 | " }\n", 464 | " console.log(responses);\n", 465 | " responsesContainer.setAttribute('data-responses', JSON.stringify(responses));\n", 466 | " printResponses(responsesContainer);\n", 467 | " }\n", 468 | " // End save responses stuff\n", 469 | "\n", 470 | "\n", 471 | "\n", 472 | " var numcorrect = fb.dataset.numcorrect;\n", 473 | " var answeredcorrect = fb.dataset.answeredcorrect;\n", 474 | " if (answeredcorrect >= 0) {\n", 475 | " fb.textContent = feedback + \" [\" + answeredcorrect + \"/\" + numcorrect + \"]\";\n", 476 | " } else {\n", 477 | " fb.textContent = feedback + \" [\" + 0 + \"/\" + numcorrect + \"]\";\n", 478 | " }\n", 479 | "\n", 480 | "\n", 481 | " }\n", 482 | "\n", 483 | " if (typeof MathJax != 'undefined') {\n", 484 | " var version = MathJax.version;\n", 485 | " console.log('MathJax version', version);\n", 486 | " if (version[0] == \"2\") {\n", 487 | " MathJax.Hub.Queue([\"Typeset\", MathJax.Hub]);\n", 488 | " } else if (version[0] == \"3\") {\n", 489 | " MathJax.typeset([fb]);\n", 490 | " }\n", 491 | " } else {\n", 492 | " console.log('MathJax not detected');\n", 493 | " }\n", 494 | "\n", 495 | "}\n", 496 | "\n", 497 | "function make_mc(qa, shuffle_answers, outerqDiv, qDiv, aDiv, id) {\n", 498 | " var shuffled;\n", 499 | " if (shuffle_answers == \"True\") {\n", 500 | " //console.log(shuffle_answers+\" read as true\");\n", 501 | " shuffled = getRandomSubarray(qa.answers, qa.answers.length);\n", 502 | " } else {\n", 503 | " //console.log(shuffle_answers+\" read as false\");\n", 504 | " shuffled = qa.answers;\n", 505 | " }\n", 506 | "\n", 507 | "\n", 508 | " var num_correct = 0;\n", 509 | "\n", 510 | "\n", 511 | "\n", 512 | " shuffled.forEach((item, index, ans_array) => {\n", 513 | " //console.log(answer);\n", 514 | "\n", 515 | " // Make input element\n", 516 | " var inp = document.createElement(\"input\");\n", 517 | " inp.type = \"radio\";\n", 518 | " inp.id = \"quizo\" + id + index;\n", 519 | " inp.style = \"display:none;\";\n", 520 | " aDiv.append(inp);\n", 521 | "\n", 522 | " //Make label for input element\n", 523 | " var lab = document.createElement(\"label\");\n", 524 | " lab.className = \"MCButton\";\n", 525 | " lab.id = id + '-' + index;\n", 526 | " lab.onclick = check_mc;\n", 527 | " var aSpan = document.createElement('span');\n", 528 | " aSpan.classsName = \"\";\n", 529 | " //qDiv.id=\"quizQn\"+id+index;\n", 530 | " if (\"answer\" in item) {\n", 531 | " aSpan.innerHTML = jaxify(item.answer);\n", 532 | " //aSpan.innerHTML=item.answer;\n", 533 | " }\n", 534 | " lab.append(aSpan);\n", 535 | "\n", 536 | " // Create div for code inside question\n", 537 | " var codeSpan;\n", 538 | " if (\"code\" in item) {\n", 539 | " codeSpan = document.createElement('span');\n", 540 | " codeSpan.id = \"code\" + id + index;\n", 541 | " codeSpan.className = \"QuizCode\";\n", 542 | " var codePre = document.createElement('pre');\n", 543 | " codeSpan.append(codePre);\n", 544 | " var codeCode = document.createElement('code');\n", 545 | " codePre.append(codeCode);\n", 546 | " codeCode.innerHTML = item.code;\n", 547 | " lab.append(codeSpan);\n", 548 | " //console.log(codeSpan);\n", 549 | " }\n", 550 | "\n", 551 | " //lab.textContent=item.answer;\n", 552 | "\n", 553 | " // Set the data attributes for the answer\n", 554 | " lab.setAttribute('data-correct', item.correct);\n", 555 | " if (item.correct) {\n", 556 | " num_correct++;\n", 557 | " }\n", 558 | " if (\"feedback\" in item) {\n", 559 | " lab.setAttribute('data-feedback', item.feedback);\n", 560 | " }\n", 561 | " lab.setAttribute('data-answered', 0);\n", 562 | "\n", 563 | " aDiv.append(lab);\n", 564 | "\n", 565 | " });\n", 566 | "\n", 567 | " if (num_correct > 1) {\n", 568 | " outerqDiv.className = \"ManyChoiceQn\";\n", 569 | " } else {\n", 570 | " outerqDiv.className = \"MultipleChoiceQn\";\n", 571 | " }\n", 572 | "\n", 573 | " return num_correct;\n", 574 | "\n", 575 | "}\n", 576 | "function check_numeric(ths, event) {\n", 577 | "\n", 578 | " if (event.keyCode === 13) {\n", 579 | " ths.blur();\n", 580 | "\n", 581 | " var id = ths.id.split('-')[0];\n", 582 | "\n", 583 | " var submission = ths.value;\n", 584 | " if (submission.indexOf('/') != -1) {\n", 585 | " var sub_parts = submission.split('/');\n", 586 | " //console.log(sub_parts);\n", 587 | " submission = sub_parts[0] / sub_parts[1];\n", 588 | " }\n", 589 | " //console.log(\"Reader entered\", submission);\n", 590 | "\n", 591 | " if (\"precision\" in ths.dataset) {\n", 592 | " var precision = ths.dataset.precision;\n", 593 | " // console.log(\"1:\", submission)\n", 594 | " submission = Math.round((1 * submission + Number.EPSILON) * 10 ** precision) / 10 ** precision;\n", 595 | " // console.log(\"Rounded to \", submission, \" precision=\", precision );\n", 596 | " }\n", 597 | "\n", 598 | "\n", 599 | " //console.log(\"In check_numeric(), id=\"+id);\n", 600 | " //console.log(event.srcElement.id) \n", 601 | " //console.log(event.srcElement.dataset.feedback)\n", 602 | "\n", 603 | " var fb = document.getElementById(\"fb\" + id);\n", 604 | " fb.style.display = \"none\";\n", 605 | " fb.textContent = \"Incorrect -- try again.\";\n", 606 | "\n", 607 | " var answers = JSON.parse(ths.dataset.answers);\n", 608 | " //console.log(answers);\n", 609 | "\n", 610 | " var defaultFB = \"\";\n", 611 | " var correct;\n", 612 | " var done = false;\n", 613 | " answers.every(answer => {\n", 614 | " //console.log(answer.type);\n", 615 | "\n", 616 | " correct = false;\n", 617 | " // if (answer.type==\"value\"){\n", 618 | " if ('value' in answer) {\n", 619 | " if (submission == answer.value) {\n", 620 | " if (\"feedback\" in answer) {\n", 621 | " fb.textContent = jaxify(answer.feedback);\n", 622 | " } else {\n", 623 | " fb.textContent = jaxify(\"Correct\");\n", 624 | " }\n", 625 | " correct = answer.correct;\n", 626 | " //console.log(answer.correct);\n", 627 | " done = true;\n", 628 | " }\n", 629 | " // } else if (answer.type==\"range\") {\n", 630 | " } else if ('range' in answer) {\n", 631 | " //console.log(answer.range);\n", 632 | " if ((submission >= answer.range[0]) && (submission < answer.range[1])) {\n", 633 | " fb.textContent = jaxify(answer.feedback);\n", 634 | " correct = answer.correct;\n", 635 | " //console.log(answer.correct);\n", 636 | " done = true;\n", 637 | " }\n", 638 | " } else if (answer.type == \"default\") {\n", 639 | " defaultFB = answer.feedback;\n", 640 | " }\n", 641 | " if (done) {\n", 642 | " return false; // Break out of loop if this has been marked correct\n", 643 | " } else {\n", 644 | " return true; // Keep looking for case that includes this as a correct answer\n", 645 | " }\n", 646 | " });\n", 647 | "\n", 648 | " if ((!done) && (defaultFB != \"\")) {\n", 649 | " fb.innerHTML = jaxify(defaultFB);\n", 650 | " //console.log(\"Default feedback\", defaultFB);\n", 651 | " }\n", 652 | "\n", 653 | " fb.style.display = \"block\";\n", 654 | " if (correct) {\n", 655 | " ths.className = \"Input-text\";\n", 656 | " ths.classList.add(\"correctButton\");\n", 657 | " fb.className = \"Feedback\";\n", 658 | " fb.classList.add(\"correct\");\n", 659 | " } else {\n", 660 | " ths.className = \"Input-text\";\n", 661 | " ths.classList.add(\"incorrectButton\");\n", 662 | " fb.className = \"Feedback\";\n", 663 | " fb.classList.add(\"incorrect\");\n", 664 | " }\n", 665 | "\n", 666 | " // What follows is for the saved responses stuff\n", 667 | " var outerContainer = fb.parentElement.parentElement;\n", 668 | " var responsesContainer = document.getElementById(\"responses\" + outerContainer.id);\n", 669 | " if (responsesContainer) {\n", 670 | " console.log(submission);\n", 671 | " var qnum = document.getElementById(\"quizWrap\"+id).dataset.qnum;\n", 672 | " //console.log(\"Question \" + qnum);\n", 673 | " //console.log(id, \", got numcorrect=\",fb.dataset.numcorrect);\n", 674 | " var responses=JSON.parse(responsesContainer.dataset.responses);\n", 675 | " console.log(responses);\n", 676 | " if (submission == ths.value){\n", 677 | " responses[qnum]= submission;\n", 678 | " } else {\n", 679 | " responses[qnum]= ths.value + \"(\" + submission +\")\";\n", 680 | " }\n", 681 | " responsesContainer.setAttribute('data-responses', JSON.stringify(responses));\n", 682 | " printResponses(responsesContainer);\n", 683 | " }\n", 684 | " // End code to preserve responses\n", 685 | "\n", 686 | " if (typeof MathJax != 'undefined') {\n", 687 | " var version = MathJax.version;\n", 688 | " console.log('MathJax version', version);\n", 689 | " if (version[0] == \"2\") {\n", 690 | " MathJax.Hub.Queue([\"Typeset\", MathJax.Hub]);\n", 691 | " } else if (version[0] == \"3\") {\n", 692 | " MathJax.typeset([fb]);\n", 693 | " }\n", 694 | " } else {\n", 695 | " console.log('MathJax not detected');\n", 696 | " }\n", 697 | " return false;\n", 698 | " }\n", 699 | "\n", 700 | "}\n", 701 | "\n", 702 | "function isValid(el, charC) {\n", 703 | " //console.log(\"Input char: \", charC);\n", 704 | " if (charC == 46) {\n", 705 | " if (el.value.indexOf('.') === -1) {\n", 706 | " return true;\n", 707 | " } else if (el.value.indexOf('/') != -1) {\n", 708 | " var parts = el.value.split('/');\n", 709 | " if (parts[1].indexOf('.') === -1) {\n", 710 | " return true;\n", 711 | " }\n", 712 | " }\n", 713 | " else {\n", 714 | " return false;\n", 715 | " }\n", 716 | " } else if (charC == 47) {\n", 717 | " if (el.value.indexOf('/') === -1) {\n", 718 | " if ((el.value != \"\") && (el.value != \".\")) {\n", 719 | " return true;\n", 720 | " } else {\n", 721 | " return false;\n", 722 | " }\n", 723 | " } else {\n", 724 | " return false;\n", 725 | " }\n", 726 | " } else if (charC == 45) {\n", 727 | " var edex = el.value.indexOf('e');\n", 728 | " if (edex == -1) {\n", 729 | " edex = el.value.indexOf('E');\n", 730 | " }\n", 731 | "\n", 732 | " if (el.value == \"\") {\n", 733 | " return true;\n", 734 | " } else if (edex == (el.value.length - 1)) { // If just after e or E\n", 735 | " return true;\n", 736 | " } else {\n", 737 | " return false;\n", 738 | " }\n", 739 | " } else if (charC == 101) { // \"e\"\n", 740 | " if ((el.value.indexOf('e') === -1) && (el.value.indexOf('E') === -1) && (el.value.indexOf('/') == -1)) {\n", 741 | " // Prev symbol must be digit or decimal point:\n", 742 | " if (el.value.slice(-1).search(/\\d/) >= 0) {\n", 743 | " return true;\n", 744 | " } else if (el.value.slice(-1).search(/\\./) >= 0) {\n", 745 | " return true;\n", 746 | " } else {\n", 747 | " return false;\n", 748 | " }\n", 749 | " } else {\n", 750 | " return false;\n", 751 | " }\n", 752 | " } else {\n", 753 | " if (charC > 31 && (charC < 48 || charC > 57))\n", 754 | " return false;\n", 755 | " }\n", 756 | " return true;\n", 757 | "}\n", 758 | "\n", 759 | "function numeric_keypress(evnt) {\n", 760 | " var charC = (evnt.which) ? evnt.which : evnt.keyCode;\n", 761 | "\n", 762 | " if (charC == 13) {\n", 763 | " check_numeric(this, evnt);\n", 764 | " } else {\n", 765 | " return isValid(this, charC);\n", 766 | " }\n", 767 | "}\n", 768 | "\n", 769 | "\n", 770 | "\n", 771 | "\n", 772 | "\n", 773 | "function make_numeric(qa, outerqDiv, qDiv, aDiv, id) {\n", 774 | "\n", 775 | "\n", 776 | "\n", 777 | " //console.log(answer);\n", 778 | "\n", 779 | "\n", 780 | " outerqDiv.className = \"NumericQn\";\n", 781 | " aDiv.style.display = 'block';\n", 782 | "\n", 783 | " var lab = document.createElement(\"label\");\n", 784 | " lab.className = \"InpLabel\";\n", 785 | " lab.textContent = \"Type numeric answer here:\";\n", 786 | " aDiv.append(lab);\n", 787 | "\n", 788 | " var inp = document.createElement(\"input\");\n", 789 | " inp.type = \"text\";\n", 790 | " //inp.id=\"input-\"+id;\n", 791 | " inp.id = id + \"-0\";\n", 792 | " inp.className = \"Input-text\";\n", 793 | " inp.setAttribute('data-answers', JSON.stringify(qa.answers));\n", 794 | " if (\"precision\" in qa) {\n", 795 | " inp.setAttribute('data-precision', qa.precision);\n", 796 | " }\n", 797 | " aDiv.append(inp);\n", 798 | " //console.log(inp);\n", 799 | "\n", 800 | " //inp.addEventListener(\"keypress\", check_numeric);\n", 801 | " //inp.addEventListener(\"keypress\", numeric_keypress);\n", 802 | " /*\n", 803 | " inp.addEventListener(\"keypress\", function(event) {\n", 804 | " return numeric_keypress(this, event);\n", 805 | " }\n", 806 | " );\n", 807 | " */\n", 808 | " //inp.onkeypress=\"return numeric_keypress(this, event)\";\n", 809 | " inp.onkeypress = numeric_keypress;\n", 810 | " inp.onpaste = event => false;\n", 811 | "\n", 812 | " inp.addEventListener(\"focus\", function (event) {\n", 813 | " this.value = \"\";\n", 814 | " return false;\n", 815 | " }\n", 816 | " );\n", 817 | "\n", 818 | "\n", 819 | "}\n", 820 | "function jaxify(string) {\n", 821 | " var mystring = string;\n", 822 | "\n", 823 | " var count = 0;\n", 824 | " var loc = mystring.search(/([^\\\\]|^)(\\$)/);\n", 825 | "\n", 826 | " var count2 = 0;\n", 827 | " var loc2 = mystring.search(/([^\\\\]|^)(\\$\\$)/);\n", 828 | "\n", 829 | " //console.log(loc);\n", 830 | "\n", 831 | " while ((loc >= 0) || (loc2 >= 0)) {\n", 832 | "\n", 833 | " /* Have to replace all the double $$ first with current implementation */\n", 834 | " if (loc2 >= 0) {\n", 835 | " if (count2 % 2 == 0) {\n", 836 | " mystring = mystring.replace(/([^\\\\]|^)(\\$\\$)/, \"$1\\\\[\");\n", 837 | " } else {\n", 838 | " mystring = mystring.replace(/([^\\\\]|^)(\\$\\$)/, \"$1\\\\]\");\n", 839 | " }\n", 840 | " count2++;\n", 841 | " } else {\n", 842 | " if (count % 2 == 0) {\n", 843 | " mystring = mystring.replace(/([^\\\\]|^)(\\$)/, \"$1\\\\(\");\n", 844 | " } else {\n", 845 | " mystring = mystring.replace(/([^\\\\]|^)(\\$)/, \"$1\\\\)\");\n", 846 | " }\n", 847 | " count++;\n", 848 | " }\n", 849 | " loc = mystring.search(/([^\\\\]|^)(\\$)/);\n", 850 | " loc2 = mystring.search(/([^\\\\]|^)(\\$\\$)/);\n", 851 | " //console.log(mystring,\", loc:\",loc,\", loc2:\",loc2);\n", 852 | " }\n", 853 | "\n", 854 | " //console.log(mystring);\n", 855 | " return mystring;\n", 856 | "}\n", 857 | "\n", 858 | "\n", 859 | "function show_questions(json, mydiv) {\n", 860 | " console.log('show_questions');\n", 861 | " //var mydiv=document.getElementById(myid);\n", 862 | " var shuffle_questions = mydiv.dataset.shufflequestions;\n", 863 | " var num_questions = mydiv.dataset.numquestions;\n", 864 | " var shuffle_answers = mydiv.dataset.shuffleanswers;\n", 865 | " var max_width = mydiv.dataset.maxwidth;\n", 866 | "\n", 867 | " if (num_questions > json.length) {\n", 868 | " num_questions = json.length;\n", 869 | " }\n", 870 | "\n", 871 | " var questions;\n", 872 | " if ((num_questions < json.length) || (shuffle_questions == \"True\")) {\n", 873 | " //console.log(num_questions+\",\"+json.length);\n", 874 | " questions = getRandomSubarray(json, num_questions);\n", 875 | " } else {\n", 876 | " questions = json;\n", 877 | " }\n", 878 | "\n", 879 | " //console.log(\"SQ: \"+shuffle_questions+\", NQ: \" + num_questions + \", SA: \", shuffle_answers);\n", 880 | "\n", 881 | " // Iterate over questions\n", 882 | " questions.forEach((qa, index, array) => {\n", 883 | " //console.log(qa.question); \n", 884 | "\n", 885 | " var id = makeid(8);\n", 886 | " //console.log(id);\n", 887 | "\n", 888 | "\n", 889 | " // Create Div to contain question and answers\n", 890 | " var iDiv = document.createElement('div');\n", 891 | " //iDiv.id = 'quizWrap' + id + index;\n", 892 | " iDiv.id = 'quizWrap' + id;\n", 893 | " iDiv.className = 'Quiz';\n", 894 | " iDiv.setAttribute('data-qnum', index);\n", 895 | " iDiv.style.maxWidth =max_width+\"px\";\n", 896 | " mydiv.appendChild(iDiv);\n", 897 | " // iDiv.innerHTML=qa.question;\n", 898 | " \n", 899 | " var outerqDiv = document.createElement('div');\n", 900 | " outerqDiv.id = \"OuterquizQn\" + id + index;\n", 901 | " // Create div to contain question part\n", 902 | " var qDiv = document.createElement('div');\n", 903 | " qDiv.id = \"quizQn\" + id + index;\n", 904 | " \n", 905 | " if (qa.question) {\n", 906 | " iDiv.append(outerqDiv);\n", 907 | "\n", 908 | " //qDiv.textContent=qa.question;\n", 909 | " qDiv.innerHTML = jaxify(qa.question);\n", 910 | " outerqDiv.append(qDiv);\n", 911 | " }\n", 912 | "\n", 913 | " // Create div for code inside question\n", 914 | " var codeDiv;\n", 915 | " if (\"code\" in qa) {\n", 916 | " codeDiv = document.createElement('div');\n", 917 | " codeDiv.id = \"code\" + id + index;\n", 918 | " codeDiv.className = \"QuizCode\";\n", 919 | " var codePre = document.createElement('pre');\n", 920 | " codeDiv.append(codePre);\n", 921 | " var codeCode = document.createElement('code');\n", 922 | " codePre.append(codeCode);\n", 923 | " codeCode.innerHTML = qa.code;\n", 924 | " outerqDiv.append(codeDiv);\n", 925 | " //console.log(codeDiv);\n", 926 | " }\n", 927 | "\n", 928 | "\n", 929 | " // Create div to contain answer part\n", 930 | " var aDiv = document.createElement('div');\n", 931 | " aDiv.id = \"quizAns\" + id + index;\n", 932 | " aDiv.className = 'Answer';\n", 933 | " iDiv.append(aDiv);\n", 934 | "\n", 935 | " //console.log(qa.type);\n", 936 | "\n", 937 | " var num_correct;\n", 938 | " if ((qa.type == \"multiple_choice\") || (qa.type == \"many_choice\") ) {\n", 939 | " num_correct = make_mc(qa, shuffle_answers, outerqDiv, qDiv, aDiv, id);\n", 940 | " if (\"answer_cols\" in qa) {\n", 941 | " //aDiv.style.gridTemplateColumns = 'auto '.repeat(qa.answer_cols);\n", 942 | " aDiv.style.gridTemplateColumns = 'repeat(' + qa.answer_cols + ', 1fr)';\n", 943 | " }\n", 944 | " } else if (qa.type == \"numeric\") {\n", 945 | " //console.log(\"numeric\");\n", 946 | " make_numeric(qa, outerqDiv, qDiv, aDiv, id);\n", 947 | " }\n", 948 | "\n", 949 | "\n", 950 | " //Make div for feedback\n", 951 | " var fb = document.createElement(\"div\");\n", 952 | " fb.id = \"fb\" + id;\n", 953 | " //fb.style=\"font-size: 20px;text-align:center;\";\n", 954 | " fb.className = \"Feedback\";\n", 955 | " fb.setAttribute(\"data-answeredcorrect\", 0);\n", 956 | " fb.setAttribute(\"data-numcorrect\", num_correct);\n", 957 | " iDiv.append(fb);\n", 958 | "\n", 959 | "\n", 960 | " });\n", 961 | " var preserveResponses = mydiv.dataset.preserveresponses;\n", 962 | " console.log(preserveResponses);\n", 963 | " console.log(preserveResponses == \"true\");\n", 964 | " if (preserveResponses == \"true\") {\n", 965 | " console.log(preserveResponses);\n", 966 | " // Create Div to contain record of answers\n", 967 | " var iDiv = document.createElement('div');\n", 968 | " iDiv.id = 'responses' + mydiv.id;\n", 969 | " iDiv.className = 'JCResponses';\n", 970 | " // Create a place to store responses as an empty array\n", 971 | " iDiv.setAttribute('data-responses', '[]');\n", 972 | "\n", 973 | " // Dummy Text\n", 974 | " iDiv.innerHTML=\"Select your answers and then follow the directions that will appear here.\"\n", 975 | " //iDiv.className = 'Quiz';\n", 976 | " mydiv.appendChild(iDiv);\n", 977 | " }\n", 978 | "//console.log(\"At end of show_questions\");\n", 979 | " if (typeof MathJax != 'undefined') {\n", 980 | " console.log(\"MathJax version\", MathJax.version);\n", 981 | " var version = MathJax.version;\n", 982 | " setTimeout(function(){\n", 983 | " var version = MathJax.version;\n", 984 | " console.log('After sleep, MathJax version', version);\n", 985 | " if (version[0] == \"2\") {\n", 986 | " MathJax.Hub.Queue([\"Typeset\", MathJax.Hub]);\n", 987 | " } else if (version[0] == \"3\") {\n", 988 | " if (MathJax.hasOwnProperty('typeset') ) {\n", 989 | " MathJax.typeset([mydiv]);\n", 990 | " } else {\n", 991 | " console.log('WARNING: Trying to force load MathJax 3');\n", 992 | " window.MathJax = {\n", 993 | " tex: {\n", 994 | " inlineMath: [['$', '$'], ['\\\\(', '\\\\)']]\n", 995 | " },\n", 996 | " svg: {\n", 997 | " fontCache: 'global'\n", 998 | " }\n", 999 | " };\n", 1000 | "\n", 1001 | " (function () {\n", 1002 | " var script = document.createElement('script');\n", 1003 | " script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js';\n", 1004 | " script.async = true;\n", 1005 | " document.head.appendChild(script);\n", 1006 | " })();\n", 1007 | " }\n", 1008 | " }\n", 1009 | " }, 500);\n", 1010 | "if (typeof version == 'undefined') {\n", 1011 | " } else\n", 1012 | " {\n", 1013 | " if (version[0] == \"2\") {\n", 1014 | " MathJax.Hub.Queue([\"Typeset\", MathJax.Hub]);\n", 1015 | " } else if (version[0] == \"3\") {\n", 1016 | " if (MathJax.hasOwnProperty('typeset') ) {\n", 1017 | " MathJax.typeset([mydiv]);\n", 1018 | " } else {\n", 1019 | " console.log('WARNING: Trying to force load MathJax 3');\n", 1020 | " window.MathJax = {\n", 1021 | " tex: {\n", 1022 | " inlineMath: [['$', '$'], ['\\\\(', '\\\\)']]\n", 1023 | " },\n", 1024 | " svg: {\n", 1025 | " fontCache: 'global'\n", 1026 | " }\n", 1027 | " };\n", 1028 | "\n", 1029 | " (function () {\n", 1030 | " var script = document.createElement('script');\n", 1031 | " script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js';\n", 1032 | " script.async = true;\n", 1033 | " document.head.appendChild(script);\n", 1034 | " })();\n", 1035 | " }\n", 1036 | " } else {\n", 1037 | " console.log(\"MathJax not found\");\n", 1038 | " }\n", 1039 | " }\n", 1040 | " }\n", 1041 | " return false;\n", 1042 | "}\n", 1043 | "/* This is to handle asynchrony issues in loading Jupyter notebooks\n", 1044 | " where the quiz has been previously run. The Javascript was generally\n", 1045 | " being run before the div was added to the DOM. I tried to do this\n", 1046 | " more elegantly using Mutation Observer, but I didn't get it to work.\n", 1047 | "\n", 1048 | " Someone more knowledgeable could make this better ;-) */\n", 1049 | "\n", 1050 | " function try_show() {\n", 1051 | " if(document.getElementById(\"HkGwvkyiZWoc\")) {\n", 1052 | " show_questions(questionsHkGwvkyiZWoc, HkGwvkyiZWoc); \n", 1053 | " } else {\n", 1054 | " setTimeout(try_show, 200);\n", 1055 | " }\n", 1056 | " };\n", 1057 | " \n", 1058 | " {\n", 1059 | " // console.log(element);\n", 1060 | "\n", 1061 | " //console.log(\"HkGwvkyiZWoc\");\n", 1062 | " // console.log(document.getElementById(\"HkGwvkyiZWoc\"));\n", 1063 | "\n", 1064 | " try_show();\n", 1065 | " }\n", 1066 | " " 1067 | ], 1068 | "text/plain": [ 1069 | "" 1070 | ] 1071 | }, 1072 | "metadata": {}, 1073 | "output_type": "display_data" 1074 | } 1075 | ], 1076 | "source": [ 1077 | "display_quiz(questions)" 1078 | ] 1079 | }, 1080 | { 1081 | "cell_type": "code", 1082 | "execution_count": null, 1083 | "metadata": {}, 1084 | "outputs": [], 1085 | "source": [] 1086 | } 1087 | ], 1088 | "metadata": { 1089 | "finalized": { 1090 | "timestamp": 1622215912635, 1091 | "trusted": true 1092 | }, 1093 | "kernelspec": { 1094 | "display_name": "Python 3 (ipykernel)", 1095 | "language": "python", 1096 | "name": "python3" 1097 | }, 1098 | "language_info": { 1099 | "codemirror_mode": { 1100 | "name": "ipython", 1101 | "version": 3 1102 | }, 1103 | "file_extension": ".py", 1104 | "mimetype": "text/x-python", 1105 | "name": "python", 1106 | "nbconvert_exporter": "python", 1107 | "pygments_lexer": "ipython3", 1108 | "version": "3.9.16" 1109 | } 1110 | }, 1111 | "nbformat": 4, 1112 | "nbformat_minor": 4 1113 | } 1114 | -------------------------------------------------------------------------------- /preserve-responses.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Demonstration of how to Preserve Student Responses in JupyterQuiz\n", 8 | "\n", 9 | "JupyterQuiz can now offer support for preserving student responses (for submission and grading, for instance). The functionality is not perfect because of limitations on feeding data from JavaScript back to Python in JupyterLab -- this is a big change since Jupyter Notebook. (This can be worked around if a Plug-In is used, but I do not want to require that.)\n", 10 | "\n", 11 | "To preserve responses requires a little prep from the notebook designer and a little extra work by the student -- detailed instructions for the student are provided.\n", 12 | "\n", 13 | "See below for a simple example:" 14 | ] 15 | }, 16 | { 17 | "cell_type": "code", 18 | "execution_count": 1, 19 | "metadata": { 20 | "tags": [ 21 | "remove-input" 22 | ] 23 | }, 24 | "outputs": [], 25 | "source": [ 26 | "from jupyterquiz import display_quiz\n", 27 | "\n", 28 | "git_path=\"https://raw.githubusercontent.com/jmshea/jupyterquiz/main/examples/\"" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 5, 34 | "metadata": {}, 35 | "outputs": [ 36 | { 37 | "data": { 38 | "text/html": [ 39 | "
      \n", 1153 | " " 1154 | ], 1155 | "text/plain": [ 1156 | "" 1157 | ] 1158 | }, 1159 | "metadata": {}, 1160 | "output_type": "display_data" 1161 | } 1162 | ], 1163 | "source": [ 1164 | "display_quiz(git_path+\"questions.json\", preserve_responses = True)" 1165 | ] 1166 | }, 1167 | { 1168 | "cell_type": "markdown", 1169 | "metadata": {}, 1170 | "source": [ 1171 | "*Replace me*" 1172 | ] 1173 | } 1174 | ], 1175 | "metadata": { 1176 | "finalized": { 1177 | "timestamp": 1622215912635, 1178 | "trusted": true 1179 | }, 1180 | "kernelspec": { 1181 | "display_name": "Python 3 (ipykernel)", 1182 | "language": "python", 1183 | "name": "python3" 1184 | }, 1185 | "language_info": { 1186 | "codemirror_mode": { 1187 | "name": "ipython", 1188 | "version": 3 1189 | }, 1190 | "file_extension": ".py", 1191 | "mimetype": "text/x-python", 1192 | "name": "python", 1193 | "nbconvert_exporter": "python", 1194 | "pygments_lexer": "ipython3", 1195 | "version": "3.9.12" 1196 | } 1197 | }, 1198 | "nbformat": 4, 1199 | "nbformat_minor": 4 1200 | } 1201 | -------------------------------------------------------------------------------- /previews/github-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmshea/jupyterquiz/a9d18e2eba2e0d76a2ff5782138f105e7b71638d/previews/github-preview.png -------------------------------------------------------------------------------- /previews/github-preview.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmshea/jupyterquiz/a9d18e2eba2e0d76a2ff5782138f105e7b71638d/previews/github-preview.psd -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "JupyterQuiz" 7 | authors = [ 8 | {name = "John M. Shea", email = "jshea@ieee.org"}] 9 | classifiers = [ "License :: OSI Approved :: MIT License",] 10 | description = "Interactive quizzes for Jupyter and Jupyter Book" 11 | readme = "README.md" 12 | dynamic = ["version"] 13 | 14 | [tool.flit.module] 15 | name = "jupyterquiz" 16 | 17 | [project.urls] 18 | home-page = "https://github.com/jmshea/jupyterquiz" 19 | -------------------------------------------------------------------------------- /schema/README: -------------------------------------------------------------------------------- 1 | Make schema diagram here: 2 | 3 | https://navneethg.github.io/jsonschemaviewer/ 4 | -------------------------------------------------------------------------------- /schema/mc_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://github.com/jmshea/jupyterquiz/mc_schema.json", 4 | "title": "JupyterQuiz Multiple or Many Choice Quiz", 5 | "description": "Schema for Multiple or Many Choice Questions in JupyterQuiz", 6 | "type": "object", 7 | "properties": { 8 | "question": { 9 | "type": "string" 10 | }, 11 | "type": { 12 | "type": "string", 13 | "pattern": "multiple_choice|many_choice" 14 | }, 15 | "answers": { 16 | "type": "array", 17 | "items": { 18 | "type": "object", 19 | "properties": { 20 | "answer": { 21 | "type": "string" 22 | }, 23 | "correct": { 24 | "type": "boolean" 25 | }, 26 | "feedback": { 27 | "type": "string" 28 | }, 29 | "answer_cols": { 30 | "type": "number" 31 | } 32 | }, 33 | "required": [ 34 | "answer", 35 | "correct" 36 | ] 37 | } 38 | }, 39 | "code": { 40 | "type": "string" 41 | } 42 | }, 43 | "required": [ 44 | "type", 45 | "question", 46 | "answers" 47 | ] 48 | } -------------------------------------------------------------------------------- /schema/mc_schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmshea/jupyterquiz/a9d18e2eba2e0d76a2ff5782138f105e7b71638d/schema/mc_schema.png -------------------------------------------------------------------------------- /schema/mc_schema.svg: -------------------------------------------------------------------------------- 1 | code:stringanswer_cols:numberfeedback:stringcorrect:boolean*answer:string*object{ }answers[ ]:array*type:string*question:string*schema{ } 2 | -------------------------------------------------------------------------------- /schema/num_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://github.com/jmshea/jupyterquiz/num_schema.json", 4 | "title": "JupyterQuiz Numeric Question", 5 | "description": "Schema for Multiple or Many Choice Questions in JupyterQuiz", 6 | "type": "object", 7 | "properties": { 8 | "question": { 9 | "type": "string" 10 | }, 11 | "type": { 12 | "type": "string", 13 | "pattern": "numeric" 14 | }, 15 | "precision": { 16 | "type": "integer" 17 | }, 18 | "answers": { 19 | "type": "array", 20 | "items": { 21 | "anyOf": [ 22 | { 23 | "type": "object", 24 | "properties": { 25 | "value": { 26 | "type": "number" 27 | }, 28 | "correct": { 29 | "type": "boolean" 30 | }, 31 | "feedback": { 32 | "type": "string" 33 | } 34 | }, 35 | "required": [ 36 | "value", 37 | "correct" 38 | ] 39 | }, 40 | { 41 | "type": "object", 42 | "properties": { 43 | "range": { 44 | "type": "array", 45 | "minItems": 2, 46 | "maxItems": 2 47 | }, 48 | "correct": { 49 | "type": "boolean" 50 | }, 51 | "feedback": { 52 | "type": "string" 53 | } 54 | }, 55 | "required": [ 56 | "range", 57 | "correct" 58 | ] 59 | }, 60 | { 61 | "type": "object", 62 | "properties": { 63 | "type": { 64 | "type": "string", 65 | "pattern": "default" 66 | }, 67 | "feedback": { 68 | "type": "string" 69 | } 70 | }, 71 | "required": [ 72 | "type", 73 | "feedback" 74 | ] 75 | } 76 | ] 77 | } 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /schema/num_schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmshea/jupyterquiz/a9d18e2eba2e0d76a2ff5782138f105e7b71638d/schema/num_schema.png -------------------------------------------------------------------------------- /schema/schema.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "c1a447f9", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "from jsonschema import validate\n" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": 2, 16 | "id": "1f4d598a", 17 | "metadata": {}, 18 | "outputs": [], 19 | "source": [ 20 | "import json\n", 21 | "with open(\"../examples/questions.json\", \"r\") as file:\n", 22 | " questions=json.load(file)" 23 | ] 24 | }, 25 | { 26 | "cell_type": "markdown", 27 | "id": "c539a78f", 28 | "metadata": { 29 | "user_expressions": [] 30 | }, 31 | "source": [ 32 | "# Schema for Multiple Choice Questions" 33 | ] 34 | }, 35 | { 36 | "cell_type": "code", 37 | "execution_count": 3, 38 | "id": "1a1577de", 39 | "metadata": {}, 40 | "outputs": [ 41 | { 42 | "data": { 43 | "text/plain": [ 44 | "{'question': 'Choose all of the following that can be included in Jupyter notebooks?',\n", 45 | " 'type': 'many_choice',\n", 46 | " 'answers': [{'answer': 'Text and graphics output from Python',\n", 47 | " 'correct': True,\n", 48 | " 'feedback': 'Correct.'},\n", 49 | " {'answer': 'Typeset mathematics', 'correct': True, 'feedback': 'Correct.'},\n", 50 | " {'answer': 'Python executable code',\n", 51 | " 'correct': True,\n", 52 | " 'feedback': 'Correct.'},\n", 53 | " {'answer': 'Formatted text', 'correct': True, 'feedback': 'Correct.'},\n", 54 | " {'answer': 'Live snakes via Python',\n", 55 | " 'correct': False,\n", 56 | " 'feedback': 'I hope not.'}]}" 57 | ] 58 | }, 59 | "execution_count": 3, 60 | "metadata": {}, 61 | "output_type": "execute_result" 62 | } 63 | ], 64 | "source": [ 65 | "multiple_choice=questions[0]\n", 66 | "multiple_choice" 67 | ] 68 | }, 69 | { 70 | "cell_type": "code", 71 | "execution_count": 6, 72 | "id": "885e0c61", 73 | "metadata": {}, 74 | "outputs": [], 75 | "source": [ 76 | "mc_schema={\n", 77 | " \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n", 78 | " \"$id\": \"https://github.com/jmshea/jupyterquiz/mc_schema.json\",\n", 79 | " \"title\": \"JupyterQuiz Multiple or Many Choice Quiz\",\n", 80 | " \"description\": \"Schema for Multiple or Many Choice Questions in JupyterQuiz\",\n", 81 | "\n", 82 | " \"type\": \"object\",\n", 83 | " \"properties\": {\n", 84 | " \"question\": {\"type\": \"string\"},\n", 85 | " \"type\": {\"type\": \"string\",\n", 86 | " \"pattern\": \"multiple_choice|many_choice\"},\n", 87 | " \"answers\": {\"type\": \"array\",\n", 88 | " \"items\":{\n", 89 | " \"type\": \"object\",\n", 90 | " \"properties\": {\n", 91 | " \"answer\": {\"type\": \"string\"},\n", 92 | " \"correct\": {\"type\": \"boolean\"},\n", 93 | " \"feedback\": {\"type\": \"string\"},\n", 94 | " \"answer_cols\": {\"type\": \"number\"}\n", 95 | " },\n", 96 | " \"required\": [\"answer\", \"correct\"]\n", 97 | " }\n", 98 | " },\n", 99 | " \"code\": {\"type\":\"string\"}\n", 100 | " },\n", 101 | " \"required\": [\"type\", \"question\", \"answers\"]\n", 102 | "\n", 103 | "\n", 104 | "\n", 105 | "}" 106 | ] 107 | }, 108 | { 109 | "cell_type": "code", 110 | "execution_count": 7, 111 | "id": "84dea887", 112 | "metadata": {}, 113 | "outputs": [], 114 | "source": [ 115 | "validate(multiple_choice, mc_schema)" 116 | ] 117 | }, 118 | { 119 | "cell_type": "code", 120 | "execution_count": 8, 121 | "id": "a320d6fb", 122 | "metadata": {}, 123 | "outputs": [], 124 | "source": [ 125 | "with open(\"mc_schema.json\", \"w\") as file:\n", 126 | " json.dump(mc_schema, file, indent=4)" 127 | ] 128 | }, 129 | { 130 | "cell_type": "markdown", 131 | "id": "59619017", 132 | "metadata": {}, 133 | "source": [ 134 | "# Schema for Numeric Questions" 135 | ] 136 | }, 137 | { 138 | "cell_type": "code", 139 | "execution_count": 7, 140 | "id": "e35c1639", 141 | "metadata": {}, 142 | "outputs": [ 143 | { 144 | "data": { 145 | "text/plain": [ 146 | "{'question': 'Enter the value of pi to 2 decimal places:',\n", 147 | " 'type': 'numeric',\n", 148 | " 'answers': [{'type': 'value',\n", 149 | " 'value': 3.14,\n", 150 | " 'correct': True,\n", 151 | " 'feedback': 'Correct.'},\n", 152 | " {'type': 'range',\n", 153 | " 'range': [3.142857, 3.142858],\n", 154 | " 'correct': True,\n", 155 | " 'feedback': 'True to 2 decimal places, but you know pi is not really 22/7, right?'},\n", 156 | " {'type': 'range',\n", 157 | " 'range': [-100000000, 0],\n", 158 | " 'correct': False,\n", 159 | " 'feedback': 'pi is the AREA of a circle of radius 1. Try again.'},\n", 160 | " {'type': 'default',\n", 161 | " 'feedback': 'pi is the area of a circle of radius 1. Try again.'}]}" 162 | ] 163 | }, 164 | "execution_count": 7, 165 | "metadata": {}, 166 | "output_type": "execute_result" 167 | } 168 | ], 169 | "source": [ 170 | "numeric=questions[2].copy()\n", 171 | "numeric" 172 | ] 173 | }, 174 | { 175 | "cell_type": "code", 176 | "execution_count": 8, 177 | "id": "a4b8bf02", 178 | "metadata": {}, 179 | "outputs": [], 180 | "source": [ 181 | "num_schema={\n", 182 | " \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n", 183 | " \"$id\": \"https://github.com/jmshea/jupyterquiz/num_schema.json\",\n", 184 | " \"title\": \"JupyterQuiz Numeric Question\",\n", 185 | " \"description\": \"Schema for Multiple or Many Choice Questions in JupyterQuiz\",\n", 186 | "\n", 187 | " \"type\": \"object\",\n", 188 | " \"properties\": {\n", 189 | " \"question\": {\"type\": \"string\"},\n", 190 | " \"type\": {\"type\": \"string\",\n", 191 | " \"pattern\": \"numeric\"},\n", 192 | " \"precision\": {\"type\": \"integer\"},\n", 193 | " \"answers\":{ \"type\": \"array\",\n", 194 | " \"items\": { \"anyOf\":\n", 195 | " [\n", 196 | " {\"type\": \"object\",\n", 197 | " \"properties\": {\n", 198 | " \"value\": {\"type\": \"number\"},\n", 199 | " \"correct\": {\"type\": \"boolean\"},\n", 200 | " \"feedback\": {\"type\": \"string\"}\n", 201 | " },\n", 202 | " \"required\":[\"value\", \"correct\"]\n", 203 | " },\n", 204 | " \n", 205 | " {\"type\": \"object\",\n", 206 | " \"properties\": {\n", 207 | " \"range\": {\n", 208 | " \"type\": \"array\",\n", 209 | " \"minItems\": 2,\n", 210 | " \"maxItems\": 2},\n", 211 | " \"correct\": {\"type\": \"boolean\"},\n", 212 | " \"feedback\": {\"type\": \"string\"}\n", 213 | " },\n", 214 | " \"required\":[\"range\", \"correct\"]\n", 215 | " },\n", 216 | " \n", 217 | " {\"type\": \"object\",\n", 218 | " \"properties\": {\n", 219 | " \"type\": {\"type\": \"string\",\n", 220 | " \"pattern\": \"default\"},\n", 221 | " \"feedback\": {\"type\": \"string\"}\n", 222 | " },\n", 223 | " \"required\":[\"type\", \"feedback\"]\n", 224 | " }\n", 225 | " ]\n", 226 | " }\n", 227 | " }\n", 228 | " }\n", 229 | "}" 230 | ] 231 | }, 232 | { 233 | "cell_type": "code", 234 | "execution_count": 9, 235 | "id": "98deeb11", 236 | "metadata": {}, 237 | "outputs": [], 238 | "source": [ 239 | "validate(numeric, num_schema)" 240 | ] 241 | }, 242 | { 243 | "cell_type": "code", 244 | "execution_count": 10, 245 | "id": "614d1c53", 246 | "metadata": {}, 247 | "outputs": [], 248 | "source": [ 249 | "with open(\"num_schema.json\", \"w\") as file:\n", 250 | " json.dump(num_schema, file, indent=4)" 251 | ] 252 | }, 253 | { 254 | "cell_type": "code", 255 | "execution_count": null, 256 | "id": "3c2686c1", 257 | "metadata": {}, 258 | "outputs": [], 259 | "source": [] 260 | } 261 | ], 262 | "metadata": { 263 | "kernelspec": { 264 | "display_name": "Python 3 (ipykernel)", 265 | "language": "python", 266 | "name": "python3" 267 | }, 268 | "language_info": { 269 | "codemirror_mode": { 270 | "name": "ipython", 271 | "version": 3 272 | }, 273 | "file_extension": ".py", 274 | "mimetype": "text/x-python", 275 | "name": "python", 276 | "nbconvert_exporter": "python", 277 | "pygments_lexer": "ipython3", 278 | "version": "3.9.15" 279 | } 280 | }, 281 | "nbformat": 4, 282 | "nbformat_minor": 5 283 | } 284 | -------------------------------------------------------------------------------- /schema/string_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://github.com/jmshea/jupyterquiz/string_schema.json", 4 | "title": "String Q", 5 | "description": "Schema for String Questions in JupyterQuiz", 6 | "type": "object", 7 | "required": [ 8 | "question", 9 | "type", 10 | "answers" 11 | ], 12 | "properties": { 13 | "question": { 14 | "type": "string" 15 | }, 16 | "type": { 17 | "type": "string" 18 | }, 19 | "answers": { 20 | "type": "array", 21 | "items": { "$ref": "#/$defs/possible_answer" } 22 | }, 23 | "input_width": { 24 | "type": "integer", 25 | "minimum": 0, 26 | "description": "Approximate width of the input text field in number of characters (applied in em units)" 27 | } 28 | }, 29 | "$defs": { 30 | "possible_answer": { 31 | "oneOf": [ 32 | { 33 | "type": "object", 34 | "properties": { 35 | "type": { 36 | "const": "default" 37 | }, 38 | "feedback": { 39 | "type": "string" 40 | } 41 | }, 42 | "required": [ 43 | "type", 44 | "feedback" 45 | ], 46 | "additionalProperties": false 47 | }, 48 | { 49 | "type": "object", 50 | "properties": { 51 | "answer": { 52 | "type": "string" 53 | }, 54 | "correct": { 55 | "type": "boolean" 56 | }, 57 | "feedback": { 58 | "type": "string" 59 | }, 60 | "match_case": { 61 | "type": "boolean" 62 | }, 63 | "fuzzy_threshold": { 64 | "type": "number" 65 | } 66 | }, 67 | "required": [ 68 | "answer", 69 | "correct" 70 | ], 71 | "additionalProperties": false 72 | } 73 | ] 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /schema/string_schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmshea/jupyterquiz/a9d18e2eba2e0d76a2ff5782138f105e7b71638d/schema/string_schema.png --------------------------------------------------------------------------------