├── logs └── .gitkeep ├── tmp └── .gitkeep ├── public ├── version.txt ├── images │ ├── favicon.ico │ ├── defaulticon.png │ ├── webwork-logo-65.png │ └── webwork_logo.svg ├── css │ ├── rtl.css │ ├── filebrowser.css │ ├── tags.css │ ├── opl-flex.css │ ├── typing-sim.css │ ├── twocolumn.css │ ├── bootstrap.scss │ ├── navbar.css │ └── crt-display.css ├── index.html ├── js │ ├── apps │ │ ├── Problem │ │ │ ├── submithelper.js │ │ │ └── problem.js │ │ ├── MathJaxConfig │ │ │ └── mathjax-config.js │ │ └── CSSMessage │ │ │ └── css-message.js │ ├── tags.js │ ├── filebrowser.js │ └── navbar.js ├── package.json └── generate-assets.js ├── Contrib ├── .dockerignore ├── Library ├── .gitattributes ├── .gitmodules ├── templates ├── RPCRenderFormats │ ├── ptx.html.ep │ ├── default.json.ep │ └── default.html.ep ├── layouts │ ├── default.html.ep │ └── navbar.html.ep ├── pages │ ├── oplUI.html.ep │ ├── twocolumn.html.ep │ └── flex.html.ep ├── columns │ ├── oplIframe.html.ep │ ├── filebrowser.html.ep │ ├── editorIframe.html.ep │ ├── editorUI.html.ep │ └── tags.html.ep └── exception.html.ep ├── script └── render_app ├── lib ├── RenderApp │ ├── Controller │ │ ├── Pages.pm │ │ ├── StaticFiles.pm │ │ └── Render.pm │ └── Model │ │ └── Problem.pm ├── WeBWorK │ ├── PreTeXt.pm │ ├── Localize.pm │ ├── Utils.pm │ ├── Localize │ │ ├── en.po │ │ ├── standalone.pot │ │ └── heb.po │ ├── Utils │ │ ├── LanguageAndDirection.pm │ │ └── Tags.pm │ └── FormatRenderedProblem.pm ├── Mojolicious │ └── Plugin │ │ └── OpenTelemetry.pm └── RenderApp.pm ├── .gitignore ├── docs └── make_translation_files.md ├── render_app.conf.dist ├── .github └── workflows │ └── createContainer.yml ├── k8 ├── Ingress.yml ├── README.md └── Renderer.yaml ├── Dockerfile ├── Dockerfile_with_OPL ├── conf └── pg_config.yml └── README.md /logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/version.txt: -------------------------------------------------------------------------------- 1 | 2.16.0 -------------------------------------------------------------------------------- /Contrib: -------------------------------------------------------------------------------- 1 | webwork-open-problem-library/Contrib -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | **/.DS_Store 4 | -------------------------------------------------------------------------------- /Library: -------------------------------------------------------------------------------- 1 | webwork-open-problem-library/OpenProblemLibrary -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | public/** linguist-vendored 2 | *.pl linguist-language=Perl 3 | -------------------------------------------------------------------------------- /public/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drdrew42/renderer/HEAD/public/images/favicon.ico -------------------------------------------------------------------------------- /public/images/defaulticon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drdrew42/renderer/HEAD/public/images/defaulticon.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/PG"] 2 | path = lib/PG 3 | url = https://github.com/openwebwork/pg.git 4 | branch = main 5 | -------------------------------------------------------------------------------- /public/images/webwork-logo-65.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drdrew42/renderer/HEAD/public/images/webwork-logo-65.png -------------------------------------------------------------------------------- /templates/RPCRenderFormats/ptx.html.ep: -------------------------------------------------------------------------------- 1 | 2 | 3 | %== $problemText 4 | %== $answerhashXML 5 | 6 | -------------------------------------------------------------------------------- /templates/layouts/default.html.ep: -------------------------------------------------------------------------------- 1 | 2 | 3 | WeBWorK Standalone Renderer 4 | <%= content %> 5 | 6 | -------------------------------------------------------------------------------- /public/css/rtl.css: -------------------------------------------------------------------------------- 1 | /* --- Modify some CSS for Right to left courses/problems --- */ 2 | 3 | /* The changes which were needed here in WeBWorK 2.16 are no 4 | * longer needed in WeBWorK 2.17. The file is being retained 5 | * for potential future use. */ 6 | 7 | -------------------------------------------------------------------------------- /script/render_app: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojo::File 'curfile'; 7 | use lib curfile->dirname->sibling('lib')->to_string; 8 | use Mojolicious::Commands; 9 | 10 | # Start command line interface for application 11 | Mojolicious::Commands->start_app('RenderApp'); 12 | -------------------------------------------------------------------------------- /public/css/filebrowser.css: -------------------------------------------------------------------------------- 1 | form { 2 | width: 100%; 3 | } 4 | 5 | .fill-height { 6 | height: 100%; 7 | } 8 | 9 | select { 10 | height: inherit; 11 | width: inherit; 12 | } 13 | 14 | .pg-file { 15 | color: rgb(0, 80, 0); 16 | font-weight: bold; 17 | } 18 | 19 | .other-file { 20 | color: rgb(126, 0, 0); 21 | } -------------------------------------------------------------------------------- /lib/RenderApp/Controller/Pages.pm: -------------------------------------------------------------------------------- 1 | package RenderApp::Controller::Pages; 2 | use Mojo::Base 'Mojolicious::Controller'; 3 | 4 | sub twocolumn { 5 | my $c = shift; 6 | $c->render(template=>'pages/twocolumn'); 7 | } 8 | 9 | sub oplUI { 10 | my $c = shift; 11 | $c->render(template=>'pages/oplUI'); 12 | } 13 | 14 | 1; -------------------------------------------------------------------------------- /templates/pages/oplUI.html.ep: -------------------------------------------------------------------------------- 1 | %= stylesheet 'css/opl-flex.css' 2 | 3 |
4 |
5 | %= include 'columns/filebrowser' 6 |
7 |
8 | %= include 'columns/oplIframe' 9 |
10 |
11 | %= include 'columns/tags' 12 |
13 |
14 | -------------------------------------------------------------------------------- /templates/pages/twocolumn.html.ep: -------------------------------------------------------------------------------- 1 | % layout 'navbar'; 2 | %= stylesheet 'css/twocolumn.css' 3 | %= javascript 'https://cdn.jsdelivr.net/npm/js-base64@3.5.2/base64.min.js' 4 | 5 |
6 |
7 | %= include 'columns/editorUI' 8 |
9 |
10 | %= include 'columns/editorIframe' 11 |
12 |
13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | render_app.conf 3 | lib/.pls-tmp-* 4 | lib/WeBWorK/htdocs/tmp/renderer/gif/* 5 | lib/WeBWorK/htdocs/tmp/renderer/images/* 6 | lib/WeBWorK/htdocs/DATA/*.json 7 | lib/WeBWorK/bin/* 8 | webwork-open-problem-library/ 9 | private/ 10 | tmp/* 11 | !tmp/.gitkeep 12 | logs/* 13 | 14 | node_modules 15 | public/**/*.min.js 16 | public/**/*.min.css 17 | public/static-assets.json 18 | 19 | *.o 20 | *.pm.tdy 21 | *.bs 22 | *.pid 23 | .idea/ 24 | -------------------------------------------------------------------------------- /templates/pages/flex.html.ep: -------------------------------------------------------------------------------- 1 | %= stylesheet 'css/opl-flex.css' 2 | 3 |
4 |
5 | %= include 'columns/filebrowser' 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
-------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | WeBWorK Placeholder Page 6 | 7 | 8 | 9 |

WeBWorK Placeholder Page

10 | 11 |

Exploring?

12 | 13 |

This page sits at the top level of the WeBWorK 2 system htdocs directory. You should never see it, unless you're verifying that you installed WeBWorK correctly.

14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /public/js/apps/Problem/submithelper.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | let problemForm = document.getElementById('problemMainForm') 3 | if (!problemForm) return; 4 | problemForm.querySelectorAll('input[type="submit"]').forEach(button => { 5 | button.addEventListener('click', () => { 6 | // Keep ONLY the last button clicked. 7 | problemForm.querySelectorAll('input[type="submit"]').forEach(clean => { 8 | clean.classList.remove('btn-clicked'); 9 | }); 10 | button.classList.add("btn-clicked"); 11 | }) 12 | }) 13 | })(); 14 | -------------------------------------------------------------------------------- /templates/columns/oplIframe.html.ep: -------------------------------------------------------------------------------- 1 | %= javascript 'node_modules/iframe-resizer/js/iframeResizer.min.js' 2 | %= stylesheet begin 3 | .pgfile-header { 4 | max-height: 30%; 5 | background-color: #ddd; 6 | overflow: auto; 7 | } 8 | .iframe-responsive { 9 | width: 100%; 10 | max-height: 100%; 11 | 12 | } 13 | %= end 14 | 15 |
16 | Pre-"DOCUMENT" pg file content 17 |
18 |
19 | 21 |
22 | -------------------------------------------------------------------------------- /docs/make_translation_files.md: -------------------------------------------------------------------------------- 1 | # How to generate translation files 2 | 3 | - Go to the location under which the renderer was installed. 4 | - You need to have `xgettext.pl` installed. 5 | - This assumes that you are starting in the directory of the renderer clone. 6 | 7 | ```bash 8 | cd lib 9 | xgettext.pl -o WeBWorK/Localize/standalone.pot -D PG/lib -D PG/macros -D RenderApp -D WeBWorK RenderApp.pm 10 | ``` 11 | 12 | - That creates the POT file of all strings found 13 | 14 | ```bash 15 | cd WeBWorK/Localize 16 | find . -name '*.po' -exec bash -c "echo \"Updating {}\"; msgmerge -qUN {} standalone.pot" \; 17 | ``` 18 | 19 | -------------------------------------------------------------------------------- /render_app.conf.dist: -------------------------------------------------------------------------------- 1 | { 2 | secrets => ['abracadabra'], 3 | baseURL => '', 4 | formURL => '', 5 | problemJWTsecret => 'shared', 6 | webworkJWTsecret => 'private', 7 | SITE_HOST => 'http://localhost:3000', 8 | CORS_ORIGIN => '*', 9 | STATIC_EXPIRES => 86400, 10 | STRICT_JWT => 0, 11 | FULL_APP_INSECURE => 0, 12 | INTERACTION_LOG => 0, 13 | hypnotoad => { 14 | listen => ['http://*:3000'], 15 | accepts => 400, 16 | workers => 10, 17 | spare => 5, 18 | clients => 100, 19 | graceful_timeout => 45, 20 | inactivity_timeout => 30, 21 | keep_alive_timeout => 30, 22 | requests => 5, 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /public/css/tags.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background-color: whitesmoke; 3 | list-style-type: none; 4 | padding: 0; 5 | } 6 | 7 | .form-row { 8 | display: flex; 9 | justify-content: flex-end; 10 | padding: .5em; 11 | } 12 | 13 | .form-row > label { 14 | padding: .5em 1em .5em 0; 15 | } 16 | 17 | .form-row > input, 18 | .form-row > textarea, 19 | .form-row > select { 20 | width: 100%; 21 | text-overflow: ellipsis; 22 | flex: 1; 23 | } 24 | 25 | .form-row > select > option { 26 | overflow:hidden; 27 | } 28 | 29 | .form-row > input, 30 | .form-row > button { 31 | padding: .5em; 32 | } 33 | 34 | .form-row > button { 35 | background: gray; 36 | color: white; 37 | border: 0; 38 | } 39 | -------------------------------------------------------------------------------- /templates/exception.html.ep: -------------------------------------------------------------------------------- 1 | %= stylesheet "$ENV{baseURL}/css/typing-sim.css" 2 | %= stylesheet "$ENV{baseURL}/css/crt-display.css" 3 | %= javascript begin 4 | window.onload = function() { 5 | let i = 0; 6 | const tag = document.getElementById('error-block'); 7 | const text = tag.getAttribute('text'); 8 | 9 | function typeWriter() { 10 | if (i <= text.length) { 11 | i++; 12 | tag.innerHTML = text.slice(0 ,i); 13 | setTimeout(typeWriter, Math.floor(Math.random()*150) + 50); 14 | } 15 | } 16 | 17 | typeWriter(); 18 | } 19 | % end 20 | 21 | % my $message = $c->stash('message') // $c->stash('exception')->message; 22 | % $message =~ s!$ENV{RENDER_ROOT}/!!g; 23 | 24 |
25 |

>

26 |
27 | -------------------------------------------------------------------------------- /templates/columns/filebrowser.html.ep: -------------------------------------------------------------------------------- 1 | %= stylesheet 'css/filebrowser.css' 2 | %= javascript 'js/filebrowser.js' 3 | 4 |
5 | Current directory path:
6 | %= form_for 'render-api/cat' => ( method => 'POST', id => 'BackNavigation') => begin 7 | %= hidden_field maxDepth => 1 8 | %= select_field basePath => ['/'], id => 'back-nav', class => 'back-nav dropdown', onchange => "updateBrowser('BackNavigation', backOut)" 9 | %= end 10 |
11 |
12 | %= form_for 'render-api/cat' => ( method => 'POST', id => 'FileBrowserForm', class => 'fill-height' ) => begin 13 | %= hidden_field maxDepth => 1 14 | %= select_field basePath => [[Contrib => 'Contrib/'], [Library => 'Library/'], [Pending => 'Pending/'], [private => 'private/']], id => 'file-list', multiple => undef, class => 'file-list', ondblclick => "updateBrowser('FileBrowserForm', diveIn)" 15 | %= end 16 |
17 | -------------------------------------------------------------------------------- /public/css/opl-flex.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0px; 7 | } 8 | 9 | .container { 10 | display: flex; 11 | flex-direction: row; 12 | align-content: stretch; 13 | height: 100%; 14 | width: 100%; 15 | } 16 | 17 | .left { 18 | display: flex; 19 | flex-direction: column; 20 | width: 25%; 21 | height: inherit; 22 | padding: 10px; 23 | background-color: #838d9f; 24 | } 25 | 26 | .middle { 27 | display: flex; 28 | flex-direction: column; 29 | flex-grow: 1; 30 | max-width: 50%; 31 | height: inherit; 32 | padding: 10px; 33 | background-color: #525a6a; 34 | } 35 | 36 | .right { 37 | display: flex; 38 | flex-direction: column; 39 | width: 25%; 40 | height: inherit; 41 | padding: 10px; 42 | background-color: #919aaa; 43 | } 44 | 45 | .header { 46 | width: 100%; 47 | margin-bottom: 10px; 48 | } 49 | 50 | .content { 51 | display: flex; 52 | flex-grow: 1; 53 | width: 100%; 54 | } 55 | -------------------------------------------------------------------------------- /templates/columns/editorIframe.html.ep: -------------------------------------------------------------------------------- 1 | %= javascript 'node_modules/iframe-resizer/js/iframeResizer.min.js' 2 | 3 |
4 | 5 |
6 |
7 |
8 | 9 |
10 |
11 |
12 | 15 | %= javascript begin 16 | iFrameResize({ checkOrigin: false, scrolling: true }, "#rendered-problem") 17 | % end 18 |
19 | -------------------------------------------------------------------------------- /templates/columns/editorUI.html.ep: -------------------------------------------------------------------------------- 1 | %= javascript 'node_modules/@openwebwork/pg-codemirror-editor/dist/pg-codemirror-editor.js' 2 | %= javascript 'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js' 3 | 4 |
5 | Editing problem:
6 |
7 |
8 |
9 |
10 |
11 | 12 | %= stylesheet begin 13 | #message{ 14 | background-color:green; 15 | } 16 | 17 | .code-mirror-editor * { 18 | font-family: unset; 19 | } 20 | 21 | .code-mirror-editor { 22 | border: 1px solid #ddd; 23 | min-height: 400px; 24 | overflow: auto; 25 | background-color: white; 26 | height: 100%; 27 | 28 | .cm-editor { 29 | height: 100%; 30 | 31 | .cm-scroller { 32 | height: 100%; 33 | 34 | .cm-content { 35 | height: 100%; 36 | min-height: 400px; 37 | } 38 | } 39 | 40 | .cm-panels { 41 | z-index: 18; 42 | } 43 | } 44 | } 45 | % end 46 | -------------------------------------------------------------------------------- /.github/workflows/createContainer.yml: -------------------------------------------------------------------------------- 1 | name: Github Packages Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - development 8 | tags: 9 | - v* 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 17 | - uses: actions/checkout@v2 18 | with: 19 | submodules: recursive 20 | 21 | - name: Extract branch/tag name 22 | shell: bash 23 | run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF##*/})" 24 | id: extract_branch 25 | 26 | # make sure you have "Improved Container Support" enabled for both your personal and/or Organization accounts! 27 | - uses: pmorelli92/github-container-registry-build-push@2.0.0 28 | name: Build and Publish latest service image 29 | with: 30 | # Read note below to see how to generate the PAT 31 | github-push-secret: ${{secrets.GITHUB_TOKEN}} 32 | docker-image-name: ww-renderer 33 | docker-image-tag: ${{ steps.extract_branch.outputs.branch }} 34 | -------------------------------------------------------------------------------- /k8/Ingress.yml: -------------------------------------------------------------------------------- 1 | apiVersion: cert-manager.io/v1 2 | kind: ClusterIssuer 3 | metadata: 4 | name: letsencrypt-prod 5 | spec: 6 | acme: 7 | # Email address used for ACME registration 8 | email: someone@example.org 9 | server: https://acme-v02.api.letsencrypt.org/directory 10 | privateKeySecretRef: 11 | # Name of a secret used to store the ACME account private key 12 | name: letsencrypt-prod-private-key 13 | # Add a single challenge solver, HTTP01 using nginx 14 | solvers: 15 | - http01: 16 | ingress: 17 | class: nginx 18 | --- 19 | apiVersion: networking.k8s.io/v1 20 | kind: Ingress 21 | metadata: 22 | name: ww-balancer 23 | annotations: 24 | kubernetes.io/ingress.class: nginx 25 | cert-manager.io/cluster-issuer: letsencrypt-prod 26 | spec: 27 | tls: 28 | - hosts: 29 | - "example.org" 30 | secretName: renderer-kubernetes-tls 31 | rules: 32 | - host: "example.org" 33 | http: 34 | paths: 35 | - pathType: Prefix 36 | path: "/" 37 | backend: 38 | service: 39 | name: wwrenderer 40 | port: 41 | number: 80 42 | -------------------------------------------------------------------------------- /k8/README.md: -------------------------------------------------------------------------------- 1 | #Deploy Renderer to Kubernetes 2 | 1. Install `kubectl`, the [official Kubernetes client](https://kubernetes.io/docs/tasks/tools/install-kubectl/). Use the most recent version of kubectl to ensure you are within one minor version of your cluster's Kubernetes version. 3 | 2. Install `doctl`, the official [DigitalOcean command-line tool](https://github.com/digitalocean/doctl), or other cloud platform-specific command-line tool. 4 | 3. Install [helm](https://helm.sh/docs/intro/install/), the kubernetes package manager. 5 | 4. Use your cloud provider's CLI tool to authenticate kubectl to your cluster. 6 | 5. Install the [Kubernetes metric server using helm](https://artifacthub.io/packages/helm/bitnami/metrics-server) onto your cluster. 7 | 6. Modify the included `Renderer.yml` and `Ingress.yml` files and use `kubectl apply -f ` to apply these configurations onto your cluster. 8 | 7. Set up a network Ingress by following this [DigitalOcean tutorial](https://www.digitalocean.com/community/tutorials/how-to-set-up-an-nginx-ingress-on-digitalocean-kubernetes-using-helm). As part of this, you will need to set up DNS records towards your cluster or load-balancer. 9 | -------------------------------------------------------------------------------- /public/css/typing-sim.css: -------------------------------------------------------------------------------- 1 | /* modified from https://codepen.io/Asadabbas/pen/joVKGE */ 2 | @import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;700&family=VT323&display=swap'); 3 | 4 | * { 5 | margin: 0; 6 | } 7 | 8 | body { 9 | background: rgb(49, 49, 49); 10 | color: lime; 11 | box-shadow: inset -53px -41px 198px black, inset 95px -2px 200px black; 12 | display:flex; 13 | align-items:center; 14 | height:100vh; 15 | justify-content: center; 16 | } 17 | 18 | .typewriter { 19 | font-family: 'VT323', monospace; 20 | /* font-weight: 700; */ 21 | width: 70%; 22 | } 23 | 24 | h1 { 25 | overflow: hidden; 26 | margin: 0 auto; 27 | display: inline-block; 28 | font-weight: normal; 29 | } 30 | 31 | h1:after { 32 | content: ''; 33 | display: inline-block; 34 | background-color: lime; 35 | margin-left: 2px; 36 | height: 25px; 37 | width: 13px; 38 | animation: cursor 0.4s infinite; 39 | } 40 | 41 | /* The typewriter cursor effect */ 42 | @keyframes cursor { 43 | 0% { opacity: 1; } 44 | 49% { opacity: 1; } 45 | 50% { opacity: 0; } 46 | 100% { opacity: 0; } 47 | } 48 | -------------------------------------------------------------------------------- /lib/RenderApp/Controller/StaticFiles.pm: -------------------------------------------------------------------------------- 1 | package RenderApp::Controller::StaticFiles; 2 | use Mojo::Base 'Mojolicious::Controller', -signatures; 3 | 4 | use Mojo::File qw(path); 5 | 6 | sub reply_with_file_if_readable ($c, $file) { 7 | if (-r $file) { 8 | return $c->reply->file($file); 9 | } else { 10 | return $c->render(data => 'File not found', status => 404); 11 | } 12 | } 13 | 14 | # Route requests for pg_files/CAPA_Graphics to render root Contrib/CAPA/CAPA_Graphics 15 | sub CAPA_graphics_file ($c) { 16 | return $c->reply_with_file_if_readable($c->app->home->child('Contrib/CAPA/CAPA_Graphics', $c->stash('static'))); 17 | } 18 | 19 | # Route requests for pg_files to the render root tmp. The 20 | # only requests should be for files in the temporary directory. 21 | # FIXME: Perhaps this directory should be configurable. 22 | sub temp_file ($c) { 23 | $c->reply_with_file_if_readable($c->app->home->child('tmp', $c->stash('static'))); 24 | } 25 | 26 | # Route request to pg_files to lib/PG/htdocs. 27 | sub pg_file ($c) { 28 | $c->reply_with_file_if_readable(path($ENV{PG_ROOT}, 'htdocs', $c->stash('static'))); 29 | } 30 | 31 | sub public_file ($c) { 32 | $c->reply_with_file_if_readable($c->app->home->child('public', $c->stash('static'))); 33 | } 34 | 35 | 1; 36 | -------------------------------------------------------------------------------- /public/js/apps/MathJaxConfig/mathjax-config.js: -------------------------------------------------------------------------------- 1 | if (!window.MathJax) { 2 | window.MathJax = { 3 | tex: { 4 | packages: {'[+]': ['noerrors']}, 5 | processEscapes: false, 6 | }, 7 | loader: { load: ['input/asciimath', '[tex]/noerrors'] }, 8 | startup: { 9 | ready: function() { 10 | var AM = MathJax.InputJax.AsciiMath.AM; 11 | for (var i = 0; i < AM.symbols.length; i++) { 12 | if (AM.symbols[i].input == '**') { 13 | AM.symbols[i] = { input: "**", tag: "msup", output: "^", tex: null, ttype: AM.TOKEN.INFIX }; 14 | } 15 | } 16 | return MathJax.startup.defaultReady() 17 | } 18 | }, 19 | options: { 20 | renderActions: { 21 | findScript: [10, function (doc) { 22 | document.querySelectorAll('script[type^="math/tex"]').forEach(function(node) { 23 | var display = !!node.type.match(/; *mode=display/); 24 | var math = new doc.options.MathItem(node.textContent, doc.inputJax[0], display); 25 | var text = document.createTextNode(''); 26 | node.parentNode.replaceChild(text, node); 27 | math.start = {node: text, delim: '', n: 0}; 28 | math.end = {node: text, delim: '', n: 0}; 29 | doc.math.push(math); 30 | }); 31 | }, ''] 32 | }, 33 | ignoreHtmlClass: 'tex2jax_ignore' 34 | } 35 | 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /k8/Renderer.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: wwrenderer 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: wwrenderer 10 | template: 11 | metadata: 12 | labels: 13 | app: wwrenderer 14 | spec: 15 | containers: 16 | - name: wwrenderer 17 | image: ghcr.io/drdrew42/ww-renderer:master 18 | resources: 19 | requests: 20 | cpu: "0.5" 21 | memory: "0.5G" 22 | limits: 23 | cpu: "1" 24 | memory: "1.5G" 25 | ports: 26 | - containerPort: 3000 27 | protocol: TCP 28 | env: 29 | - name: SITE_HOST 30 | value: https://example.org/ 31 | --- 32 | kind: Service 33 | apiVersion: v1 34 | metadata: 35 | name: wwrenderer 36 | spec: 37 | # type: LoadBalancer 38 | type: ClusterIP 39 | selector: 40 | app: wwrenderer 41 | ports: 42 | - name: http 43 | protocol: TCP 44 | port: 80 45 | targetPort: 3000 46 | --- 47 | apiVersion: autoscaling/v2beta1 48 | kind: HorizontalPodAutoscaler 49 | metadata: 50 | name: wwrenderer 51 | spec: 52 | scaleTargetRef: 53 | apiVersion: apps/v1 54 | kind: Deployment 55 | name: wwrenderer 56 | minReplicas: 1 57 | maxReplicas: 30 58 | metrics: 59 | - type: Resource 60 | resource: 61 | name: cpu 62 | targetAverageUtilization: 50 63 | -------------------------------------------------------------------------------- /lib/WeBWorK/PreTeXt.pm: -------------------------------------------------------------------------------- 1 | package WeBWorK::PreTeXt; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojo::DOM; 7 | use Mojo::IOLoop; 8 | use Data::Structure::Util qw(unbless); 9 | 10 | use lib "$ENV{PG_ROOT}/lib"; 11 | use WeBWorK::PG; 12 | 13 | sub render_ptx { 14 | my $p = shift; 15 | my $source = $p->{rawProblemSource}; 16 | 17 | return Mojo::IOLoop->subprocess->run_p(sub { 18 | my $pg = WeBWorK::PG->new( 19 | showSolutions => 1, 20 | showHints => 1, 21 | processAnswers => 1, 22 | displayMode => 'PTX', 23 | language_subroutine => WeBWorK::PG::Localize::getLoc('en'), 24 | problemSeed => $p->{problemSeed} // 1234, 25 | $p->{problemUUID} ? (problemUUID => $p->{problemUUID}) : (), 26 | $p->{sourceFilePath} ? (sourceFilePath => $p->{sourceFilePath}) : (), 27 | $source ? (r_source => \$source) : () 28 | ); 29 | 30 | my $dom = Mojo::DOM->new->xml(1); 31 | for my $answer (sort keys %{ $pg->{answers} }) { 32 | $dom->append_content($dom->new_tag( 33 | $answer, map { $_ => ($pg->{answers}{$answer}{$_} // '') } keys %{ $pg->{answers}{$answer} } 34 | )); 35 | } 36 | $dom->wrap_content(''); 37 | 38 | my $ret = { problemText => $pg->{body_text}, answerhashXML => $dom->to_string }; 39 | 40 | $pg->free; 41 | return $ret; 42 | })->catch(sub { 43 | my $err = shift; 44 | return "error: $err"; 45 | }); 46 | } 47 | 1; 48 | -------------------------------------------------------------------------------- /public/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "renderer.javascript_package_manager", 3 | "description": "Third party javascript for Standalone Renderer", 4 | "license": "GPL-2.0+", 5 | "scripts": { 6 | "generate-assets": "node generate-assets", 7 | "prepare": "npm run generate-assets" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/openwebwork/renderer" 12 | }, 13 | "dependencies": { 14 | "@fortawesome/fontawesome-free": "^6.2.1", 15 | "@openwebwork/pg-codemirror-editor": "^0.0.4", 16 | "bootstrap": "~5.2.3", 17 | "iframe-resizer": "^4.3.2", 18 | "jquery": "^3.6.3", 19 | "jquery-ui-dist": "^1.13.2", 20 | "mathjax": "^3.2.2" 21 | }, 22 | "devDependencies": { 23 | "autoprefixer": "^10.4.13", 24 | "chokidar": "^3.5.3", 25 | "cssnano": "^6.0.0", 26 | "postcss": "^8.4.21", 27 | "rtlcss": "^4.0.0", 28 | "sass": "^1.57.1", 29 | "terser": "^5.16.1", 30 | "yargs": "^17.6.2" 31 | }, 32 | "browserslist": [ 33 | "last 10 Chrome versions", 34 | "last 10 Firefox versions", 35 | "last 4 Edge versions", 36 | "last 7 Safari versions", 37 | "last 8 Android versions", 38 | "last 8 ChromeAndroid versions", 39 | "last 8 FirefoxAndroid versions", 40 | "last 10 iOS versions", 41 | "last 5 Opera versions" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /templates/layouts/navbar.html.ep: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | WeBWorK Standalone Renderer 6 | 7 | 8 | 9 | %= stylesheet 'css/navbar.css' 10 | 11 | 12 |
13 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 |
33 | <%= content %> 34 | %= javascript 'js/navbar.js' 35 | 36 | 37 | -------------------------------------------------------------------------------- /public/css/twocolumn.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | /* Create two equal columns that floats next to each other */ 6 | .column { 7 | width: 50%; 8 | height: auto; 9 | padding: 10px; 10 | position: absolute; 11 | z-index: 1; 12 | display: block; 13 | bottom: 0; 14 | top: 47px; 15 | } 16 | 17 | .left { 18 | left: 0; 19 | background-color:#aaa; 20 | } 21 | 22 | .right { 23 | right: 0; 24 | background-color:#bbb; 25 | } 26 | 27 | /* Clear floats after the columns */ 28 | .row:after { 29 | content: ""; 30 | display: table; 31 | clear: both; 32 | } 33 | 34 | .content { 35 | position: absolute; 36 | top: 50px; 37 | bottom: 0px; 38 | left: 0px; 39 | right: 0px; 40 | padding: 10px; 41 | } 42 | 43 | .iframe-header { 44 | position: relative; 45 | overflow: hidden; 46 | } 47 | 48 | .iframe-responsive { 49 | top: 0; 50 | left: 0; 51 | width: 100%; 52 | max-height: 100%; 53 | min-width: 100%; 54 | border: 0; 55 | } 56 | 57 | /* Responsive layout - makes the two columns stack on top of each other instead of next to each other */ 58 | @media screen and (max-width: 600px) { 59 | .column { 60 | width: 100%; 61 | } 62 | } 63 | 64 | #currentEditPath { 65 | font-size: 11px; 66 | padding: 5px; 67 | padding-bottom: 13px; 68 | } 69 | 70 | .iframe-header button { 71 | float: left; 72 | padding-top: 6px; 73 | padding-right: 10px; 74 | padding-bottom: 6px; 75 | padding-left: 10px; 76 | margin-bottom: 8px; 77 | margin-left: 16px; 78 | background: #ddd; 79 | font-size: 17px; 80 | border: none; 81 | cursor: pointer; 82 | } 83 | 84 | .iframe-header .render-option { 85 | padding-top: 6px; 86 | padding-right: 10px; 87 | padding-bottom: 6px; 88 | padding-left: 10px; 89 | margin-bottom: 8px; 90 | margin-left: 16px; 91 | display: block; 92 | float: left; 93 | } 94 | 95 | .iframe-header button:hover { 96 | background: #ccc; 97 | } 98 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | LABEL org.opencontainers.image.source=https://github.com/openwebwork/renderer 3 | 4 | WORKDIR /usr/app 5 | ARG DEBIAN_FRONTEND=noninteractive 6 | ENV TZ=America/New_York 7 | 8 | RUN apt-get update \ 9 | && apt-get install -y --no-install-recommends --no-install-suggests \ 10 | apt-utils \ 11 | git \ 12 | gcc \ 13 | make \ 14 | curl \ 15 | dvipng \ 16 | openssl \ 17 | libc-dev \ 18 | cpanminus \ 19 | libssl-dev \ 20 | libgd-perl \ 21 | zlib1g-dev \ 22 | imagemagick \ 23 | libdbi-perl \ 24 | libjson-perl \ 25 | libcgi-pm-perl \ 26 | libjson-xs-perl \ 27 | ca-certificates \ 28 | libstorable-perl \ 29 | libdatetime-perl \ 30 | libuuid-tiny-perl \ 31 | libtie-ixhash-perl \ 32 | libhttp-async-perl \ 33 | libnet-ssleay-perl \ 34 | libarchive-zip-perl \ 35 | libcrypt-ssleay-perl \ 36 | libclass-accessor-perl \ 37 | libstring-shellquote-perl \ 38 | libextutils-cbuilder-perl \ 39 | libproc-processtable-perl \ 40 | libmath-random-secure-perl \ 41 | libdata-structure-util-perl \ 42 | liblocale-maketext-lexicon-perl \ 43 | libyaml-libyaml-perl \ 44 | && curl -fsSL https://deb.nodesource.com/setup_16.x | bash - \ 45 | && apt-get install -y --no-install-recommends --no-install-suggests nodejs \ 46 | && apt-get clean \ 47 | && rm -fr /var/lib/apt/lists/* /tmp/* 48 | 49 | RUN cpanm install Mojo::Base Statistics::R::IO::Rserve Date::Format Future::AsyncAwait Crypt::JWT IO::Socket::SSL CGI::Cookie \ 50 | && rm -fr ./cpanm /root/.cpanm /tmp/* 51 | 52 | COPY . . 53 | 54 | RUN cp render_app.conf.dist render_app.conf 55 | 56 | RUN cp conf/pg_config.yml lib/PG/conf/pg_config.yml 57 | 58 | RUN cd public/ && npm install && cd .. 59 | 60 | RUN cd lib/PG/htdocs && npm install && cd ../../.. 61 | 62 | EXPOSE 3000 63 | 64 | HEALTHCHECK CMD curl -I localhost:3000/health 65 | 66 | CMD hypnotoad -f ./script/render_app 67 | -------------------------------------------------------------------------------- /public/js/apps/CSSMessage/css-message.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('message', event => { 2 | let message; 3 | try { 4 | message = JSON.parse(event.data); 5 | } 6 | catch (e) { 7 | if (!event.data.startsWith('[iFrameSizer]')) console.warn('CSSMessage: message not JSON', event.data); 8 | return; 9 | } 10 | 11 | if (message.hasOwnProperty('elements')) { 12 | message.elements.forEach((incoming) => { 13 | let elements; 14 | if (incoming.hasOwnProperty('selector')) { 15 | elements = window.document.querySelectorAll(incoming.selector); 16 | if (incoming.hasOwnProperty('style')) { 17 | elements.forEach(el => { el.style.cssText = incoming.style }); 18 | } 19 | if (incoming.hasOwnProperty('class')) { 20 | elements.forEach(el => { el.className = incoming.class }); 21 | } 22 | } 23 | }); 24 | event.source.postMessage(JSON.stringify({ type: "webwork.css.update", update: "elements updated"}), event.origin); 25 | } 26 | 27 | if (message.hasOwnProperty('templates')) { 28 | message.templates.forEach((cssString) => { 29 | const element = document.createElement('style'); 30 | element.innerText = cssString; 31 | document.head.insertAdjacentElement('beforeend', element); 32 | }); 33 | event.source.postMessage(JSON.stringify({ type: "webwork.css.update", update: "templates updated"}), event.origin); 34 | } 35 | 36 | if (message.hasOwnProperty('showSolutions')) { 37 | const elements = Array.from(window.document.querySelectorAll('.knowl[data-type="solution"]')); 38 | const solutions = elements.map(el => el.dataset.knowlContents); 39 | event.source.postMessage(JSON.stringify({ type: "webwork.content.solutions", solutions: solutions }), event.origin); 40 | } 41 | 42 | if (message.hasOwnProperty('showHints')) { 43 | const elements = Array.from(window.document.querySelectorAll('.knowl[data-type="hint"]')); 44 | const hints = elements.map(el => el.dataset.knowlContents); 45 | event.source.postMessage(JSON.stringify({ type: "webwork.content.hints", hints: hints }), event.origin); 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /lib/WeBWorK/Localize.pm: -------------------------------------------------------------------------------- 1 | package WeBWorK::Localize; 2 | 3 | use File::Spec; 4 | 5 | use Locale::Maketext; 6 | use Locale::Maketext::Lexicon; 7 | 8 | my $path = "$ENV{RENDER_ROOT}/lib/WeBWorK/Localize"; 9 | my $pattern = File::Spec->catfile($path, '*.[pm]o'); 10 | my $decode = 1; 11 | my $encoding = undef; 12 | 13 | eval " 14 | package WeBWorK::Localize::I18N; 15 | use base 'Locale::Maketext'; 16 | %WeBWorK::Localize::I18N::Lexicon = ( '_AUTO' => 1 ); 17 | Locale::Maketext::Lexicon->import({ 18 | 'i-default' => [ 'Auto' ], 19 | '*' => [ Gettext => \$pattern ], 20 | _decode => \$decode, 21 | _encoding => \$encoding, 22 | }); 23 | *tense = sub { \$_[1] . ((\$_[2] eq 'present') ? 'ing' : 'ed') }; 24 | 25 | " or die "Can't process eval in WeBWorK/Localize.pm: line 35: ". $@; 26 | 27 | package WeBWorK::Localize; 28 | 29 | # This subroutine is shared with the safe compartment in PG to 30 | # allow maketext() to be constructed in PG problems and macros 31 | # It seems to be a little fragile -- possibly it breaks 32 | # on perl 5.8.8 33 | sub getLoc { 34 | my $lang = shift; 35 | my $lh = WeBWorK::Localize::I18N->get_handle($lang); 36 | return sub {$lh->maketext(@_)}; 37 | } 38 | 39 | sub getLangHandle { 40 | my $lang = shift; 41 | my $lh = WeBWorK::Localize::I18N->get_handle($lang); 42 | return $lh; 43 | } 44 | 45 | # this is like [quant] but it doesn't write the number 46 | # usage: [quant,_1,,,] 47 | 48 | sub plural { 49 | my($handle, $num, @forms) = @_; 50 | 51 | return "" if @forms == 0; 52 | return $forms[2] if @forms > 2 and $num == 0; 53 | 54 | # Normal case: 55 | return( $handle->numerate($num, @forms) ); 56 | } 57 | 58 | # this is like [quant] but it also has -1 case 59 | # usage: [negquant,_1,,,,] 60 | 61 | sub negquant { 62 | my($handle, $num, @forms) = @_; 63 | 64 | return $num if @forms == 0; 65 | 66 | my $negcase = shift @forms; 67 | return $negcase if $num < 0; 68 | 69 | return $forms[2] if @forms > 2 and $num == 0; 70 | return( $handle->numf($num) . ' ' . $handle->numerate($num, @forms) ); 71 | } 72 | 73 | %Lexicon = ( 74 | '_AUTO' => 1, 75 | ); 76 | 77 | package WeBWorK::Localize::I18N; 78 | use base(WeBWorK::Localize); 79 | 80 | 1; 81 | -------------------------------------------------------------------------------- /Dockerfile_with_OPL: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | LABEL org.opencontainers.image.source=https://github.com/openwebwork/renderer 3 | 4 | WORKDIR /usr/app 5 | ARG DEBIAN_FRONTEND=noninteractive 6 | ENV TZ=America/New_York 7 | 8 | RUN apt-get update \ 9 | && apt-get install -y --no-install-recommends --no-install-suggests \ 10 | apt-utils \ 11 | git \ 12 | gcc \ 13 | make \ 14 | curl \ 15 | dvipng \ 16 | openssl \ 17 | libc-dev \ 18 | cpanminus \ 19 | libssl-dev \ 20 | libgd-perl \ 21 | zlib1g-dev \ 22 | imagemagick \ 23 | libdbi-perl \ 24 | libjson-perl \ 25 | libcgi-pm-perl \ 26 | libjson-xs-perl \ 27 | ca-certificates \ 28 | libstorable-perl \ 29 | libdatetime-perl \ 30 | libuuid-tiny-perl \ 31 | libtie-ixhash-perl \ 32 | libhttp-async-perl \ 33 | libnet-ssleay-perl \ 34 | libarchive-zip-perl \ 35 | libcrypt-ssleay-perl \ 36 | libclass-accessor-perl \ 37 | libstring-shellquote-perl \ 38 | libextutils-cbuilder-perl \ 39 | libproc-processtable-perl \ 40 | libmath-random-secure-perl \ 41 | libdata-structure-util-perl \ 42 | liblocale-maketext-lexicon-perl \ 43 | libyaml-libyaml-perl \ 44 | && curl -fsSL https://deb.nodesource.com/setup_16.x | bash - \ 45 | && apt-get install -y --no-install-recommends --no-install-suggests nodejs \ 46 | && apt-get clean \ 47 | && rm -fr /var/lib/apt/lists/* /tmp/* 48 | 49 | RUN cpanm install Mojo::Base Statistics::R::IO::Rserve Date::Format Future::AsyncAwait Crypt::JWT IO::Socket::SSL CGI::Cookie \ 50 | && rm -fr ./cpanm /root/.cpanm /tmp/* 51 | 52 | ENV MOJO_MODE=production 53 | 54 | # Clones the OPL into the container. Should be the only difference from ./Dockerfile 55 | RUN curl -sOL "https://github.com/openwebwork/webwork-open-problem-library/archive/refs/heads/master.tar.gz" 56 | RUN tar -zxf master.tar.gz 57 | RUN mkdir webwork-open-problem-library 58 | RUN rm master.tar.gz 59 | RUN mv webwork-open-problem-library-master/OpenProblemLibrary/ webwork-open-problem-library/OpenProblemLibrary/ 60 | RUN mv webwork-open-problem-library-master/Contrib/ webwork-open-problem-library/Contrib/ 61 | RUN rm -r webwork-open-problem-library-master/ 62 | 63 | COPY . . 64 | 65 | RUN cp render_app.conf.dist render_app.conf 66 | 67 | RUN cp conf/pg_config.yml lib/PG/conf/pg_config.yml 68 | 69 | RUN npm install 70 | 71 | RUN cd lib/PG/htdocs && npm install && cd ../../.. 72 | 73 | EXPOSE 3000 74 | 75 | HEALTHCHECK CMD curl -I localhost:3000/health 76 | 77 | CMD hypnotoad -f ./script/render_app 78 | -------------------------------------------------------------------------------- /public/css/bootstrap.scss: -------------------------------------------------------------------------------- 1 | // Include functions first (so you can manipulate colors, SVGs, calc, etc) 2 | @import "../node_modules/bootstrap/scss/functions"; 3 | 4 | // Variable overrides 5 | 6 | // Enable shadows and gradients. These are disabled by default. 7 | $enable-shadows: true; 8 | 9 | // Use a smaller grid gutter width. The default is 1.5rem. 10 | $grid-gutter-width: 1rem; 11 | 12 | // Fonts 13 | $font-size-base: 0.85rem; 14 | $headings-font-weight: 600; 15 | 16 | // Links 17 | $link-decoration: none; 18 | $link-hover-decoration: underline; 19 | 20 | // Make breadcrumb dividers and active items a bit darker. 21 | $breadcrumb-divider-color: #495057; 22 | $breadcrumb-active-color: #495057; 23 | 24 | // Include the remainder of bootstrap's scss configuration 25 | @import "../node_modules/bootstrap/scss/variables"; 26 | @import "../node_modules/bootstrap/scss/maps"; 27 | @import "../node_modules/bootstrap/scss/mixins"; 28 | @import "../node_modules/bootstrap/scss/utilities"; 29 | 30 | // Layout & components 31 | @import "../node_modules/bootstrap/scss/root"; 32 | @import "../node_modules/bootstrap/scss/reboot"; 33 | @import "../node_modules/bootstrap/scss/type"; 34 | @import "../node_modules/bootstrap/scss/images"; 35 | @import "../node_modules/bootstrap/scss/containers"; 36 | @import "../node_modules/bootstrap/scss/grid"; 37 | @import "../node_modules/bootstrap/scss/tables"; 38 | @import "../node_modules/bootstrap/scss/forms"; 39 | @import "../node_modules/bootstrap/scss/buttons"; 40 | @import "../node_modules/bootstrap/scss/transitions"; 41 | @import "../node_modules/bootstrap/scss/dropdown"; 42 | @import "../node_modules/bootstrap/scss/button-group"; 43 | @import "../node_modules/bootstrap/scss/nav"; 44 | @import "../node_modules/bootstrap/scss/navbar"; 45 | @import "../node_modules/bootstrap/scss/card"; 46 | @import "../node_modules/bootstrap/scss/accordion"; 47 | @import "../node_modules/bootstrap/scss/breadcrumb"; 48 | @import "../node_modules/bootstrap/scss/pagination"; 49 | @import "../node_modules/bootstrap/scss/badge"; 50 | @import "../node_modules/bootstrap/scss/alert"; 51 | @import "../node_modules/bootstrap/scss/placeholders"; 52 | @import "../node_modules/bootstrap/scss/progress"; 53 | @import "../node_modules/bootstrap/scss/list-group"; 54 | @import "../node_modules/bootstrap/scss/close"; 55 | @import "../node_modules/bootstrap/scss/toasts"; 56 | @import "../node_modules/bootstrap/scss/modal"; 57 | @import "../node_modules/bootstrap/scss/tooltip"; 58 | @import "../node_modules/bootstrap/scss/popover"; 59 | @import "../node_modules/bootstrap/scss/carousel"; 60 | @import "../node_modules/bootstrap/scss/spinners"; 61 | @import "../node_modules/bootstrap/scss/offcanvas"; 62 | 63 | // Helpers 64 | @import "../node_modules/bootstrap/scss/helpers"; 65 | 66 | // Utilities 67 | @import "../node_modules/bootstrap/scss/utilities/api"; 68 | 69 | // Overrides 70 | a:not(.btn):focus { 71 | color: $link-hover-color; 72 | outline-style: solid; 73 | outline-color: $link-hover-color; 74 | outline-width: 1px; 75 | } 76 | -------------------------------------------------------------------------------- /public/js/tags.js: -------------------------------------------------------------------------------- 1 | function updateDBsubject() { 2 | var subjectSelect = window.document.getElementById('db-subject'); 3 | var subjects = Object.keys(taxo) || []; 4 | addOptions(subjectSelect, subjects); 5 | } 6 | 7 | function updateDBchapter() { 8 | var subjectSelect = window.document.getElementById('db-subject'); 9 | var subject = subjectSelect.options[subjectSelect.selectedIndex]?.value; 10 | var chapterSelect = window.document.getElementById('db-chapter') 11 | var chapters = (taxo[subject]) ? Object.keys(taxo[subject]) : []; 12 | addOptions(chapterSelect, chapters); 13 | } 14 | 15 | function updateDBsection() { 16 | var subjectSelect = window.document.getElementById('db-subject'); 17 | var subject = subjectSelect.options[subjectSelect.selectedIndex]?.value; 18 | var chapterSelect = window.document.getElementById('db-chapter'); 19 | var chapter = chapterSelect.options[chapterSelect.selectedIndex]?.value; 20 | var sectionSelect = window.document.getElementById('db-section'); 21 | var sections = (taxo[subject] && taxo[subject][chapter]) ? taxo[subject][chapter] : []; 22 | addOptions(sectionSelect, sections); 23 | } 24 | 25 | function addOptions(selectElement, optionsArray) { 26 | if (selectElement.innerHTML) { selectElement.innerHTML = '' } 27 | optionsArray.forEach(function (opt) { 28 | var option = document.createElement('option'); 29 | option.value = opt; 30 | option.text = opt; 31 | selectElement.add(option); 32 | }); 33 | var emptyOption = document.createElement('option'); 34 | emptyOption.value = ''; 35 | emptyOption.text = 'blank'; 36 | selectElement.add(emptyOption, 0); 37 | selectElement.selectedIndex = 0; 38 | } 39 | 40 | function submitTags(e) { 41 | e.preventDefault(); 42 | var formData = new FormData(e.target); 43 | 44 | // disassemble the Description 45 | formData = parseStringAndAppend(formData, 'Description'); 46 | 47 | // disassemble the list of keywords 48 | formData = parseStringAndAppend(formData, 'Keywords'); 49 | 50 | // disassemble any resources 51 | formData = parseStringAndAppend(formData, 'Resources'); 52 | 53 | var params = { 54 | body: formData, 55 | method: 'post' 56 | }; 57 | fetch(e.target.action, params) 58 | .then( function (resp) { 59 | if (resp.ok) { 60 | return resp.json(); 61 | } else { 62 | throw new Error("Something went wrong: " + resp.statusText); 63 | } 64 | }) 65 | .then( d => updateMetadata(d) ) 66 | .catch( e => {console.log(e); alert(e.message);} ); 67 | } 68 | 69 | // uses the convention that tags want these arrays as lowercase 70 | // UI uses the joined string in key with first-capital 71 | function parseStringAndAppend(formData, elementName) { 72 | var string = window.document.getElementsByName(elementName)[0].value; 73 | if (string && string !== '') { 74 | var array = string.split(',').map(el => el.trim()); 75 | array.forEach(item => formData.append(elementName.toLowerCase(), item)); 76 | formData.delete(elementName); 77 | } 78 | return formData; 79 | } 80 | 81 | updateDBsubject(); 82 | window.document.getElementById('problem-tags').addEventListener('submit', submitTags); -------------------------------------------------------------------------------- /public/css/navbar.css: -------------------------------------------------------------------------------- 1 | * {box-sizing: border-box;} 2 | 3 | body * { 4 | margin: 0; 5 | font-family: 'Montserrat', sans-serif; 6 | font-weight: 200; 7 | } 8 | 9 | .topnav { 10 | position: absolute; 11 | top: 0; 12 | left: 0; 13 | width: 100%; 14 | height: 50px; 15 | /* overflow: hidden; */ 16 | background-color: #012C4E; 17 | } 18 | 19 | .topnav a { 20 | float: left; 21 | display: block; 22 | color: white; 23 | text-align: center; 24 | padding: 14px 16px; 25 | text-decoration: none; 26 | font-size: 17px; 27 | } 28 | 29 | .topnav a:hover .dropdown:hover .dropbtn { 30 | background-color: #00BCD4; 31 | color: black; 32 | } 33 | 34 | .topnav a.active { 35 | background-color: #012C4E; 36 | color: white; 37 | } 38 | 39 | .dropdown { 40 | float: left; 41 | /* overflow: hidden; */ 42 | } 43 | 44 | .dropdown .dropbtn { 45 | font-size: 16px; 46 | border: none; 47 | outline: none; 48 | color: white; 49 | padding: 14px 16px; 50 | background-color: inherit; 51 | font-family: inherit; 52 | margin: 0; 53 | } 54 | 55 | .dropdown-content { 56 | display: none; 57 | position: fixed; 58 | background-color: #f9f9f9; 59 | min-width: 160px; 60 | box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); 61 | z-index: 5; 62 | } 63 | 64 | .dropdown-content a { 65 | float: none; 66 | color: white; 67 | background-color: #012C4E; 68 | padding: 12px 16px; 69 | text-decoration: none; 70 | display: block; 71 | text-align: left; 72 | } 73 | 74 | .dropdown-content a:hover { 75 | background-color: #607B90; 76 | color: black; 77 | } 78 | 79 | .dropdown:hover .dropdown-content { 80 | display: block; 81 | } 82 | 83 | .topnav .search-container { 84 | float: right; 85 | } 86 | 87 | .topnav input[type=text] { 88 | padding: 6px; 89 | margin-top: 7px; 90 | margin-right: 16px; 91 | font-size: 17px; 92 | border: none; 93 | } 94 | 95 | .topnav .search-container button { 96 | float: right; 97 | padding: 6px 10px; 98 | margin-top: 7px; 99 | margin-right: 16px; 100 | background: #C0CAD3; 101 | font-size: 17px; 102 | border: none; 103 | cursor: pointer; 104 | } 105 | 106 | .topnav .search-container button:hover { 107 | background: #A0B0BD; 108 | } 109 | 110 | @media screen and (max-width: 600px) { 111 | .topnav .search-container { 112 | float: none; 113 | } 114 | .topnav a, .topnav input[type=text], .topnav .search-container button { 115 | float: none; 116 | display: block; 117 | text-align: left; 118 | width: 100%; 119 | margin: 0; 120 | padding: 14px; 121 | } 122 | .topnav input[type=text] { 123 | border: 1px solid #ccc; 124 | } 125 | } 126 | 127 | .dropdown-item .fa { 128 | display: none; 129 | } 130 | 131 | .dropdown-item.selected .fa { 132 | display: inline-block; 133 | } 134 | 135 | #problemSeed { 136 | background-color: #DFE5E9; 137 | color: #2B193D; 138 | } 139 | 140 | #sourceFilePath{ 141 | border:none; 142 | background-color: #DFE5E9; 143 | color:#2B193D; 144 | min-width:130px; 145 | } 146 | 147 | #hiddenSourceFilePath{ 148 | display:none; 149 | white-space:pre; 150 | padding: 6px; 151 | margin-top: 7px; 152 | margin-right: 16px; 153 | font-size: 17px; 154 | } 155 | -------------------------------------------------------------------------------- /templates/RPCRenderFormats/default.json.ep: -------------------------------------------------------------------------------- 1 | % use Mojo::JSON qw(to_json); 2 | % use WeBWorK::Utils qw(wwRound); 3 | % 4 | % my $json_output = { 5 | % head_part001 => "", 6 | % head_part010 => q{} 7 | % . qq{}, 8 | % head_part300 => join('', 9 | % (map { stylesheet($_) } @$third_party_css), 10 | % (map { stylesheet($_->{file}) } @$extra_css_files), 11 | % (map { javascript($_->[0], %{ $_->[1] // {} }) } @$third_party_js), 12 | % (map { javascript($_->{file}, %{ $_->{attributes} }) } @$extra_js_files), 13 | % $rh_result->{header_text} // '', 14 | % $rh_result->{post_header_text} // '', 15 | % $extra_header_text 16 | % ), 17 | % head_part400 => 'WeBWorK problem', 18 | % head_part999 => '', 19 | % 20 | % body_part001 => '', 21 | % body_part100 => '
', 22 | % body_part300 => $answerTemplate, 23 | % body_part500 => '
', 25 | % body_part530 => qq{
}, 26 | % body_part550 => $problemText, 27 | % body_part590 => '
', 28 | % body_part650 => '

' . $lh->maketext('You received a score of [_1] for this attempt.', 29 | % wwRound(0, $rh_result->{problem_result}{score} * 100) . '%') . '

' 30 | % . ($rh_result->{problem_result}{msg} ? ('

' . $rh_result->{problem_result}{msg} . '

') : '') 31 | % . hidden_field('problem-result-score' => $rh_result->{problem_result}{score}, 32 | % id => 'problem-result-score'), 33 | % body_part700 => $formatName eq 'static' ? '' : join('', '

', 34 | % $showPreviewButton eq '0' ? '' : submit_button($lh->maketext('Preview My Answers'), 35 | % name => 'preview', id => 'previewAnswers_id', class => 'btn btn-primary mb-1'), 36 | % $showCheckAnswersButton eq '0' ? '' : submit_button($lh->maketext('Check Answers'), 37 | % name => 'WWsubmit', class => 'btn btn-primary mb-1'), 38 | % $showCorrectAnswersButton eq '0' ? '' : submit_button($lh->maketext('Show Correct Answers'), 39 | % name => 'WWcorrectAns', class => 'btn btn-primary mb-1'), 40 | % '

'), 41 | % body_part999 => '
' 42 | % . ($showFooter eq '0' ? '' 43 | % : qq{") 45 | % . '}', 46 | % 47 | % hidden_input_field => { 48 | % sessionJWT => $rh_result->{sessionJWT}, 49 | % ($rh_result->{JWTanswerURLstatus}) ? (JWTanswerURLstatus => $rh_result->{JWTanswerURLstatus}) : (), 50 | % }, 51 | % 52 | % # Add the current score to the json output 53 | % score => $rh_result->{inputs_ref}{submitAnswers} && $rh_result->{problem_result} 54 | % ? wwRound(0, $rh_result->{problem_result}{score} * 100) 55 | % : 0, 56 | % 57 | % # These are the real WeBWorK server URLs which the intermediate needs to use 58 | % # to communicate with WW, while the distant client must use URLs of the 59 | % # intermediate server (the man in the middle). 60 | % real_webwork_SITE_URL => $SITE_URL, 61 | % real_webwork_FORM_ACTION_URL => $FORM_ACTION_URL, 62 | % internal_problem_lang_and_dir => $PROBLEM_LANG_AND_DIR 63 | % }; 64 | % 65 | %== to_json($json_output) 66 | -------------------------------------------------------------------------------- /lib/Mojolicious/Plugin/OpenTelemetry.pm: -------------------------------------------------------------------------------- 1 | package Mojolicious::Plugin::OpenTelemetry; 2 | # ABSTRACT: An OpenTelemetry integration for Mojolicious 3 | 4 | our $VERSION = '0.002'; 5 | 6 | use Mojo::Base 'Mojolicious::Plugin', -signatures; 7 | 8 | use Feature::Compat::Try; 9 | use OpenTelemetry -all; 10 | use OpenTelemetry::Constants -span; 11 | use Syntax::Keyword::Dynamically; 12 | 13 | sub register ($, $app, $config, @) { 14 | $config->{tracer}{name} //= otel_config('SERVICE_NAME') // $app->moniker; 15 | 16 | $app->hook( 17 | around_action => sub ($next, $c, $action, $last, @) { 18 | return $next->() unless $last; 19 | 20 | my $tracer = otel_tracer_provider->tracer(%{ $config->{tracer} }); 21 | 22 | my $tx = $c->tx; 23 | my $req = $tx->req; 24 | my $url = $req->url; 25 | my $route = $c->match->endpoint->to_string; 26 | my $query = $url->query->to_string; 27 | my $method = $req->method; 28 | my $headers = $req->headers; 29 | my $agent = $headers->user_agent; 30 | 31 | # https://opentelemetry.io/docs/specs/semconv/http/http-spans/#setting-serveraddress-and-serverport-attributes 32 | my $hostport; 33 | if (my $fwd = $headers->header('forwarded')) { 34 | my ($first) = split ',', $fwd, 2; 35 | $hostport = $1 // $2 if $first =~ /host=(?:"([^"]+)"|([^;]+))/; 36 | } 37 | 38 | $hostport //= $headers->header('x-forwarded-proto') // $headers->header('host'); 39 | 40 | my ($host, $port) = $hostport =~ /(.*?)(?::([0-9]+))?$/g; 41 | 42 | my $context = 43 | otel_propagator->extract($headers, undef, sub ($carrier, $key) { $carrier->header($key) },); 44 | 45 | my $span = $tracer->create_span( 46 | name => $method . ' ' . $route, 47 | kind => SPAN_KIND_SERVER, 48 | parent => $context, 49 | attributes => { 50 | 'http.request.method' => $method, 51 | 'network.protocol.version' => $req->version, 52 | 'url.path' => $url->path->to_string, 53 | 'url.scheme' => $url->scheme, 54 | 'http.route' => $route, 55 | 'client.address' => $tx->remote_address, 56 | 'client.port' => $tx->remote_port, 57 | $host ? ('server.address' => $host) : (), 58 | $port ? ('server.port' => $port) : (), 59 | $agent ? ('user_agent.original' => $agent) : (), 60 | $query ? ('url.query' => $query) : (), 61 | }, 62 | ); 63 | 64 | dynamically otel_current_context = otel_context_with_span($span, $context); 65 | 66 | try { 67 | my @result; 68 | my $want = wantarray; 69 | 70 | if ($want) { @result = $next->() } 71 | else { $result[0] = $next->() } 72 | 73 | my $promise = $result[0]->can('then') ? $result[0] : Mojo::Promise->resolve(1); 74 | 75 | $promise->then(sub { 76 | my $code = $tx->res->code; 77 | my $error = $code >= 400 && $code < 600; 78 | 79 | $span->set_status($error ? SPAN_STATUS_ERROR : SPAN_STATUS_OK) 80 | ->set_attribute('http.response.status_code' => $code)->end; 81 | })->wait; 82 | 83 | return $want ? @result : $result[0]; 84 | } catch ($error) { 85 | my ($message) = split /\n/, "$error", 2; 86 | $message =~ s/ at \S+ line \d+\.$//a; 87 | 88 | $span->record_exception($error)->set_status(SPAN_STATUS_ERROR, $message)->set_attribute( 89 | 'error.type' => ref $error || 'string', 90 | 'http.response.status_code' => 500, 91 | )->end; 92 | 93 | die $error; 94 | } 95 | } 96 | ); 97 | } 98 | 99 | 1; 100 | -------------------------------------------------------------------------------- /templates/columns/tags.html.ep: -------------------------------------------------------------------------------- 1 | %= stylesheet 'css/tags.css' 2 | 3 | % my $taxo = ''; 4 | % if ( open(TAXONOMY, "<:encoding(utf8)", $c->app->home->child('tmp/tagging-taxonomy.json')) ) { 5 | % $taxo = join("", ); 6 | % close TAXONOMY; 7 | % } else { die "Could not open Taxonomy!"; } 8 | 9 |
10 | %= form_for 'render-api/tags' => ( method => 'POST', id => 'problem-tags' ) => begin 11 | %= hidden_field 'file' => '', id => 'tag-filename' 12 |
13 |
14 | %= label_for 'Status' => 'Review Status: ' 15 | %= select_field 'Status' => [['not reviewed' => ''], ['Accepted' => 'A'], ['Missing resource' => 'N'], ['Hold for further review' => 'F'], ['Rejected' => 'R']] 16 |
17 |
18 | %= label_for 'Description' => 'Description: ' 19 | %= text_area 'Description' 20 |
21 |
22 | %= label_for 'Author' => 'Author: ' 23 | %= text_field 'Author' 24 |
25 |
26 | %= label_for 'Institution' => 'Institution: ' 27 | %= text_field 'Institution' 28 |
29 |
30 | %= label_for 'Date' => 'Date: ' 31 | %= text_field 'Date' 32 |
33 |
34 | %= label_for 'DBsubject' => 'DBsubject: ' 35 | %= select_field 'DBsubject' => [''], id => 'db-subject', onchange => 'updateDBchapter()' 36 |
37 |
38 | %= label_for 'DBchapter' => 'DBchapter: ' 39 | %= select_field 'DBchapter' => [''], id => 'db-chapter', onchange => 'updateDBsection()' 40 |
41 |
42 | %= label_for 'DBsection' => 'DBsection: ' 43 | %= select_field 'DBsection' => [''], id => 'db-section' 44 |
45 |
46 | %= label_for 'Level' => 'Level: ' 47 | %= select_field 'Level' => [1,2,3,4,5,6] 48 | 49 |
50 |
51 | %= label_for 'Language' => 'Language: ' 52 | %= text_field 'Language' 53 |
54 |
55 | %= label_for 'Keywords' => 'Keywords: ' 56 | %= text_field 'Keywords' 57 |
58 |
59 | %= label_for 'Resources' => 'Resources: ' 60 | %= text_field 'Resources' 61 |
62 |
63 | %= label_for 'MO' => 'MathObjects: ' 64 | %= check_box 'MO', id => 'MO', value => 1 65 |
66 |
67 | %= label_for 'Static' => 'Static: ' 68 | %= check_box 'Static', id => 'Static', value => 1 69 |
70 |
71 |
72 |
73 | %= label_for 'hintExists' => 'Hint provided: ' 74 | %= check_box 'hintExists', id => 'hintExists', value => 1 75 |
76 |
77 | %= label_for 'solutionExists' => 'Solution provided: ' 78 | %= check_box 'solutionExists', id => 'solutionExists', value => 1 79 |
80 |
81 |
82 | %= submit_button 'Save Tags' 83 |
84 | %= end 85 |
86 | 87 | %= javascript begin 88 | taxo = <%== $taxo %>; 89 | %= end 90 | %= javascript 'js/tags.js' 91 | -------------------------------------------------------------------------------- /lib/WeBWorK/Utils.pm: -------------------------------------------------------------------------------- 1 | package WeBWorK::Utils; 2 | use base qw(Exporter); 3 | 4 | use strict; 5 | use warnings; 6 | 7 | use JSON; 8 | 9 | our @EXPORT_OK = qw( 10 | wwRound 11 | getAssetURL 12 | ); 13 | 14 | # usage wwRound($places,$float) 15 | # return $float rounded up to number of decimal places given by $places 16 | sub wwRound(@) { 17 | my $places = shift; 18 | my $float = shift; 19 | my $factor = 10**$places; 20 | return int($float * $factor + 0.5) / $factor; 21 | } 22 | 23 | my $staticWWAssets; 24 | my $staticPGAssets; 25 | my $thirdPartyWWDependencies; 26 | my $thirdPartyPGDependencies; 27 | 28 | sub readJSON { 29 | my $fileName = shift; 30 | 31 | return unless -r $fileName; 32 | 33 | open(my $fh, "<:encoding(UTF-8)", $fileName) or die "FATAL: Unable to open '$fileName'!"; 34 | local $/; 35 | my $data = <$fh>; 36 | close $fh; 37 | 38 | return JSON->new->decode($data); 39 | } 40 | 41 | sub getThirdPartyAssetURL { 42 | my ($file, $dependencies, $baseURL, $useCDN) = @_; 43 | 44 | for (keys %$dependencies) { 45 | if ($file =~ /^node_modules\/$_\/(.*)$/) { 46 | if ($useCDN && $1 !~ /mathquill/) { 47 | return 48 | "https://cdn.jsdelivr.net/npm/$_\@" 49 | . substr($dependencies->{$_}, 1) . '/' 50 | . ($1 =~ s/(?:\.min)?\.(js|css)$/.min.$1/gr); 51 | } else { 52 | return Mojo::URL->new("${baseURL}$file")->query(version => $dependencies->{$_} =~ s/#/@/gr); 53 | } 54 | } 55 | } 56 | return; 57 | } 58 | 59 | # Get the url for static assets. 60 | sub getAssetURL { 61 | my ($language, $file) = @_; 62 | 63 | # Load the static files list generated by `npm install` the first time this method is called. 64 | unless ($staticWWAssets) { 65 | my $staticAssetsList = "$ENV{RENDER_ROOT}/public/static-assets.json"; 66 | $staticWWAssets = readJSON($staticAssetsList); 67 | unless ($staticWWAssets) { 68 | warn "ERROR: '$staticAssetsList' not found or not readable!\n" 69 | . "You may need to run 'npm install' from '$ENV{RENDER_ROOT}/public'."; 70 | $staticWWAssets = {}; 71 | } 72 | } 73 | 74 | unless ($staticPGAssets) { 75 | my $staticAssetsList = "$ENV{PG_ROOT}/htdocs/static-assets.json"; 76 | $staticPGAssets = readJSON($staticAssetsList); 77 | unless ($staticPGAssets) { 78 | warn "ERROR: '$staticAssetsList' not found or not readable!\n" 79 | . "You may need to run 'npm install' from '$ENV{PG_ROOT}/htdocs'."; 80 | $staticPGAssets = {}; 81 | } 82 | } 83 | 84 | unless ($thirdPartyWWDependencies) { 85 | my $packageJSON = "$ENV{RENDER_ROOT}/public/package.json"; 86 | my $data = readJSON($packageJSON); 87 | warn "ERROR: '$packageJSON' not found or not readable!\n" unless $data && defined $data->{dependencies}; 88 | $thirdPartyWWDependencies = $data->{dependencies} // {}; 89 | } 90 | 91 | unless ($thirdPartyPGDependencies) { 92 | my $packageJSON = "$ENV{PG_ROOT}/htdocs/package.json"; 93 | my $data = readJSON($packageJSON); 94 | warn "ERROR: '$packageJSON' not found or not readable!\n" unless $data && defined $data->{dependencies}; 95 | $thirdPartyPGDependencies = $data->{dependencies} // {}; 96 | } 97 | 98 | # Check to see if this is a third party asset file in node_modules (either in webwork2/htdocs or pg/htdocs). 99 | # If so, then either serve it from a CDN if requested, or serve it directly with the library version 100 | # appended as a URL parameter. 101 | if ($file =~ /^node_modules/) { 102 | my $wwFile = getThirdPartyAssetURL( 103 | $file, $thirdPartyWWDependencies, 104 | '', 105 | 0 106 | ); 107 | return $wwFile if $wwFile; 108 | 109 | my $pgFile = 110 | getThirdPartyAssetURL($file, $thirdPartyPGDependencies, 'pg_files/', 1); 111 | return $pgFile if $pgFile; 112 | } 113 | 114 | # If a right-to-left language is enabled (Hebrew or Arabic) and this is a css file that is not a third party asset, 115 | # then determine the rtl varaint file name. This will be looked for first in the asset lists. 116 | my $rtlfile = 117 | ($language =~ /^(he|ar)/ && $file !~ /node_modules/ && $file =~ /\.css$/) 118 | ? $file =~ s/\.css$/.rtl.css/r 119 | : undef; 120 | 121 | # First check to see if this is a file in the webwork htdocs location with a rtl variant. 122 | return "$staticWWAssets->{$rtlfile}" 123 | if defined $rtlfile && defined $staticWWAssets->{$rtlfile}; 124 | 125 | # Next check to see if this is a file in the webwork htdocs location. 126 | return "$staticWWAssets->{$file}" if defined $staticWWAssets->{$file}; 127 | 128 | # Now check to see if this is a file in the pg htdocs location with a rtl variant. 129 | return "pg_files/$staticPGAssets->{$rtlfile}" if defined $rtlfile && defined $staticPGAssets->{$rtlfile}; 130 | 131 | # Next check to see if this is a file in the pg htdocs location. 132 | return "pg_files/$staticPGAssets->{$file}" if defined $staticPGAssets->{$file}; 133 | 134 | # If the file was not found in the lists, then just use the given file and assume its path is relative to the 135 | # render app public folder. 136 | return "$file"; 137 | } 138 | 139 | 1; 140 | -------------------------------------------------------------------------------- /public/css/crt-display.css: -------------------------------------------------------------------------------- 1 | /* http://aleclownes.com/2017/02/01/crt-display.html */ 2 | @keyframes flicker { 3 | 0% { 4 | opacity: 0.27861; 5 | } 6 | 5% { 7 | opacity: 0.34769; 8 | } 9 | 10% { 10 | opacity: 0.23604; 11 | } 12 | 15% { 13 | opacity: 0.90626; 14 | } 15 | 20% { 16 | opacity: 0.18128; 17 | } 18 | 25% { 19 | opacity: 0.83891; 20 | } 21 | 30% { 22 | opacity: 0.65583; 23 | } 24 | 35% { 25 | opacity: 0.67807; 26 | } 27 | 40% { 28 | opacity: 0.26559; 29 | } 30 | 45% { 31 | opacity: 0.84693; 32 | } 33 | 50% { 34 | opacity: 0.96019; 35 | } 36 | 55% { 37 | opacity: 0.08594; 38 | } 39 | 60% { 40 | opacity: 0.20313; 41 | } 42 | 65% { 43 | opacity: 0.71988; 44 | } 45 | 70% { 46 | opacity: 0.53455; 47 | } 48 | 75% { 49 | opacity: 0.37288; 50 | } 51 | 80% { 52 | opacity: 0.71428; 53 | } 54 | 85% { 55 | opacity: 0.70419; 56 | } 57 | 90% { 58 | opacity: 0.7003; 59 | } 60 | 95% { 61 | opacity: 0.36108; 62 | } 63 | 100% { 64 | opacity: 0.24387; 65 | } 66 | } 67 | @keyframes textShadow { 68 | 0% { 69 | text-shadow: 0.4389924193300864px 0 1px rgba(0,30,255,0.5), -0.4389924193300864px 0 1px rgba(255,0,80,0.3), 0 0 3px; 70 | } 71 | 5% { 72 | text-shadow: 2.7928974010788217px 0 1px rgba(0,30,255,0.5), -2.7928974010788217px 0 1px rgba(255,0,80,0.3), 0 0 3px; 73 | } 74 | 10% { 75 | text-shadow: 0.02956275843481219px 0 1px rgba(0,30,255,0.5), -0.02956275843481219px 0 1px rgba(255,0,80,0.3), 0 0 3px; 76 | } 77 | 15% { 78 | text-shadow: 0.40218538552878136px 0 1px rgba(0,30,255,0.5), -0.40218538552878136px 0 1px rgba(255,0,80,0.3), 0 0 3px; 79 | } 80 | 20% { 81 | text-shadow: 3.4794037899852017px 0 1px rgba(0,30,255,0.5), -3.4794037899852017px 0 1px rgba(255,0,80,0.3), 0 0 3px; 82 | } 83 | 25% { 84 | text-shadow: 1.6125630401149584px 0 1px rgba(0,30,255,0.5), -1.6125630401149584px 0 1px rgba(255,0,80,0.3), 0 0 3px; 85 | } 86 | 30% { 87 | text-shadow: 0.7015590085143956px 0 1px rgba(0,30,255,0.5), -0.7015590085143956px 0 1px rgba(255,0,80,0.3), 0 0 3px; 88 | } 89 | 35% { 90 | text-shadow: 3.896914047650351px 0 1px rgba(0,30,255,0.5), -3.896914047650351px 0 1px rgba(255,0,80,0.3), 0 0 3px; 91 | } 92 | 40% { 93 | text-shadow: 3.870905614848819px 0 1px rgba(0,30,255,0.5), -3.870905614848819px 0 1px rgba(255,0,80,0.3), 0 0 3px; 94 | } 95 | 45% { 96 | text-shadow: 2.231056963361899px 0 1px rgba(0,30,255,0.5), -2.231056963361899px 0 1px rgba(255,0,80,0.3), 0 0 3px; 97 | } 98 | 50% { 99 | text-shadow: 0.08084290417898504px 0 1px rgba(0,30,255,0.5), -0.08084290417898504px 0 1px rgba(255,0,80,0.3), 0 0 3px; 100 | } 101 | 55% { 102 | text-shadow: 2.3758461067427543px 0 1px rgba(0,30,255,0.5), -2.3758461067427543px 0 1px rgba(255,0,80,0.3), 0 0 3px; 103 | } 104 | 60% { 105 | text-shadow: 2.202193051050636px 0 1px rgba(0,30,255,0.5), -2.202193051050636px 0 1px rgba(255,0,80,0.3), 0 0 3px; 106 | } 107 | 65% { 108 | text-shadow: 2.8638780614874975px 0 1px rgba(0,30,255,0.5), -2.8638780614874975px 0 1px rgba(255,0,80,0.3), 0 0 3px; 109 | } 110 | 70% { 111 | text-shadow: 0.48874025155497314px 0 1px rgba(0,30,255,0.5), -0.48874025155497314px 0 1px rgba(255,0,80,0.3), 0 0 3px; 112 | } 113 | 75% { 114 | text-shadow: 1.8948491305757957px 0 1px rgba(0,30,255,0.5), -1.8948491305757957px 0 1px rgba(255,0,80,0.3), 0 0 3px; 115 | } 116 | 80% { 117 | text-shadow: 0.0833037308038857px 0 1px rgba(0,30,255,0.5), -0.0833037308038857px 0 1px rgba(255,0,80,0.3), 0 0 3px; 118 | } 119 | 85% { 120 | text-shadow: 0.09769827255241735px 0 1px rgba(0,30,255,0.5), -0.09769827255241735px 0 1px rgba(255,0,80,0.3), 0 0 3px; 121 | } 122 | 90% { 123 | text-shadow: 3.443339761481782px 0 1px rgba(0,30,255,0.5), -3.443339761481782px 0 1px rgba(255,0,80,0.3), 0 0 3px; 124 | } 125 | 95% { 126 | text-shadow: 2.1841838852799786px 0 1px rgba(0,30,255,0.5), -2.1841838852799786px 0 1px rgba(255,0,80,0.3), 0 0 3px; 127 | } 128 | 100% { 129 | text-shadow: 2.6208764473832513px 0 1px rgba(0,30,255,0.5), -2.6208764473832513px 0 1px rgba(255,0,80,0.3), 0 0 3px; 130 | } 131 | } 132 | .crt::after { 133 | content: " "; 134 | display: block; 135 | position: absolute; 136 | top: 0; 137 | left: 0; 138 | bottom: 0; 139 | right: 0; 140 | background: rgba(18, 16, 16, 0.1); 141 | opacity: 0; 142 | z-index: 2; 143 | pointer-events: none; 144 | animation: flicker 0.15s infinite; 145 | } 146 | .crt::before { 147 | content: " "; 148 | display: block; 149 | position: absolute; 150 | top: 0; 151 | left: 0; 152 | bottom: 0; 153 | right: 0; 154 | background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06)); 155 | z-index: 2; 156 | background-size: 100% 2px, 3px 100%; 157 | pointer-events: none; 158 | } 159 | .crt { 160 | animation: textShadow 1.6s infinite; 161 | } 162 | -------------------------------------------------------------------------------- /templates/RPCRenderFormats/default.html.ep: -------------------------------------------------------------------------------- 1 | % use WeBWorK::Utils qw(getAssetURL wwRound); 2 | % 3 | 4 | > 5 | 6 | 7 | 8 | 9 | WeBWorK using host: <%= $SITE_URL %>, 10 | format: <%= $formatName %>, 11 | 12 | " rel="shortcut icon"> 13 | % # Add third party css and javascript as well as css and javascript requested by the problem. 14 | % for (@$third_party_css) { 15 | %= stylesheet $_ 16 | % } 17 | % for (@$extra_css_files) { 18 | %= stylesheet $_->{file} 19 | % } 20 | % for (@$third_party_js) { 21 | %= javascript $_->[0], %{ $_->[1] // {} } 22 | % } 23 | % for (@$extra_js_files) { 24 | %= javascript $_->{file}, %{ $_->{attributes} } 25 | % } 26 | %== $rh_result->{header_text} // '' 27 | %== $rh_result->{post_header_text} // '' 28 | %== $extra_header_text 29 | 30 | 31 |
32 |
33 |
34 | %== $resultSummary 35 | <%= form_for $FORM_ACTION_URL, id => 'problemMainForm', class => 'problem-main-form', 36 | name => 'problemMainForm', method => 'POST', begin %> 37 |
> 38 | %== $problemText 39 |
40 | % if ($showScoreSummary) { 41 |

<%= $lh->maketext('You received a score of [_1] for this attempt.', 42 | wwRound(0, $rh_result->{problem_result}{score} * 100) . '%') %>

43 | % if ($rh_result->{problem_result}{msg}) { 44 |

<%= $rh_result->{problem_result}{msg} %>

45 | % } 46 | <%= hidden_field 'problem-result-score' => $rh_result->{problem_result}{score}, 47 | id => 'problem-result-score' %> 48 | % } 49 | %= hidden_field sessionJWT => $rh_result->{sessionJWT} 50 | % if ($rh_result->{JWTanswerURLstatus}) { 51 | %= hidden_field JWTanswerURLstatus => $rh_result->{JWTanswerURLstatus} 52 | % } 53 | % if ($formatName eq 'debug' && $rh_result->{inputs_ref}{clientDebug}) { 54 | %= hidden_field clientDebug => $rh_result->{inputs_ref}{clientDebug} 55 | % } 56 | % if ($formatName ne 'static') { 57 |
58 | % # Submit buttons (preview and submit are shown by default) 59 | % if ($showPreviewButton ne '0') { 60 | <%= submit_button $lh->maketext('Preview My Answers'), 61 | name => 'previewAnswers', id => 'previewAnswers_id', class => 'btn btn-primary mb-1' %> 62 | % } 63 | % if ($showCheckAnswersButton ne '0') { 64 | <%= submit_button $lh->maketext('Submit Answers'), 65 | name => 'submitAnswers', class => 'btn btn-primary mb-1' %> 66 | % } 67 | % if ($showCorrectAnswersButton ne '0') { 68 | <%= submit_button $lh->maketext('Show Correct Answers'), 69 | name => 'showCorrectAnswers', class => 'btn btn-primary mb-1' %> 70 | % } 71 |
72 | % } 73 | % end 74 |
75 |
76 | % # PG warning messages (this includes translator warnings but not translator errors). 77 | % if ($rh_result->{pg_warnings}) { 78 |
79 |

<%= $lh->maketext('Warning messages') %>

80 |
    81 | % for (split("\n", $rh_result->{pg_warnings})) { 82 |
  • <%== $_ %>
  • 83 | % } 84 |
85 |
86 | % } 87 | % # PG warning messages generated with WARN_message. 88 | % if (ref $rh_result->{warning_messages} eq 'ARRAY' && @{ $rh_result->{warning_messages} }) { 89 |
90 |

<%= $lh->maketext('PG warning messages') %>

91 |
    92 | % for (@{ $rh_result->{warning_messages} }) { 93 |
  • <%== $_ %>
  • 94 | % } 95 |
96 |
97 | % } 98 | % # Translator errors. 99 | % if ($rh_result->{flags}{error_flag}) { 100 |
101 |

Translator errors

102 | <%== $rh_result->{errors} %> 103 |
104 | % } 105 | % # Additional information output only for the debug format. 106 | % if ($formatName eq 'debug') { 107 | % # PG debug messages generated with DEBUG_message. 108 | % if (@{ $rh_result->{debug_messages} }) { 109 |
110 |

PG debug messages

111 |
    112 | % for (@{ $rh_result->{debug_messages} }) { 113 |
  • <%== $_ %>
  • 114 | % } 115 |
116 |
117 | % } 118 | % # Internal debug messages generated within PGcore. 119 | % if (ref $rh_result->{internal_debug_messages} eq 'ARRAY' && @{ $rh_result->{internal_debug_messages} }) { 120 |
121 |

Internal errors

122 |
    123 | % for (@{ $rh_result->{internal_debug_messages} }) { 124 |
  • <%== $_ %>
  • 125 | % } 126 |
127 |
128 | % } 129 | % if ($rh_result->{inputs_ref}{clientDebug}) { 130 |

Webwork client data

131 | %== $pretty_print->($rh_result) 132 | % } 133 | % } 134 |
135 | % # Show the footer unless it is explicity disabled. 136 | % if ($showFooter ne '0') { 137 | 142 | % } 143 | 144 | 145 | -------------------------------------------------------------------------------- /public/js/apps/Problem/problem.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const frame = window.frameElement.id || window.frameElement.dataset.id || 'no-id'; 3 | // Activate the popovers in the results table. 4 | document.querySelectorAll('.attemptResults .answer-preview[data-bs-toggle="popover"]') 5 | .forEach((preview) => { 6 | if (preview.dataset.bsContent) 7 | new bootstrap.Popover(preview); 8 | }); 9 | 10 | // if there is a JWTanswerURLstatus element, report it to parent 11 | const status = document.getElementById('JWTanswerURLstatus')?.value; 12 | if (status) { 13 | console.log("problem status updated:", JSON.parse(value)); 14 | window.parent.postMessage(value, '*'); 15 | } 16 | 17 | // fetch the problem-result-score and postMessage to parent 18 | const score = document.getElementById('problem-result-score')?.value; 19 | if (score) { 20 | window.parent.postMessage(JSON.stringify({ 21 | type: 'webwork.interaction.attempt', 22 | status: score, 23 | frame: frame, 24 | }), '*'); 25 | } 26 | 27 | // set up listeners on knowl hints and solutions 28 | document.querySelectorAll('.knowl[data-type="hint"]').forEach((hint) => { 29 | hint.addEventListener('click', (event) => { 30 | window.parent.postMessage(JSON.stringify({ 31 | type: 'webwork.interaction.hint', 32 | status: hint.classList[1], 33 | id: hint.dataset.bsTarget, 34 | frame: frame, 35 | }), '*'); 36 | }); 37 | }); 38 | 39 | document.querySelectorAll('.knowl[data-type="solution"]').forEach((solution) => { 40 | solution.addEventListener('click', (event) => { 41 | window.parent.postMessage(JSON.stringify({ 42 | type: 'webwork.interaction.solution', 43 | status: solution.classList[1], 44 | id: solution.dataset.bsTarget, 45 | frame: frame, 46 | }), '*'); 47 | }); 48 | }); 49 | 50 | // set up listeners on the form for focus in/out, because they will bubble up to form 51 | // and because we don't want to juggle mathquill elements 52 | const form = document.getElementById('problemMainForm'); 53 | let messageQueue = []; 54 | let messageTimer = null; 55 | 56 | function processMessageQueue() { 57 | // Process the original messages in the queue 58 | for (let message = messageQueue.pop(); message; message = messageQueue.pop()) { 59 | window.parent.postMessage(JSON.stringify(message), '*'); 60 | } 61 | 62 | // Clear the message queue and timer 63 | messageQueue = []; 64 | clearTimeout(messageTimer); 65 | messageTimer = null; 66 | } 67 | 68 | // interrupt the blur/focus/blur/focus sequence caused by the toolbar 69 | function checkForButtonClick() { 70 | // using unshift so most recent is at the front 71 | if (messageQueue[0].type !== 'webwork.interaction.focus') return; 72 | 73 | // toolbar interaction focus/blur happens in between matching ids 74 | const id = messageQueue[0].id; 75 | if (messageQueue[3].id !== id) return; 76 | 77 | // toolbar interaction is focus/blur with same id, ends with answer id 78 | if (!messageQueue[1].id.endsWith(id) 79 | || !messageQueue[2].id.endsWith(id) 80 | || messageQueue[1].id !== messageQueue[2].id) return; 81 | 82 | // if we get here, we have a toolbar interaction 83 | const button = messageQueue[1].id.replace(`-${id}`, ''); 84 | messageQueue.splice(0, 4, { 85 | type: 'webwork.interaction.toolbar', 86 | id: button, 87 | }); 88 | } 89 | 90 | function scheduleMessage(message) { 91 | messageQueue.unshift(message); 92 | 93 | if (messageQueue.length >= 4) { 94 | checkForButtonClick(); 95 | } 96 | 97 | if (messageTimer) clearTimeout(messageTimer); 98 | messageTimer = setTimeout(processMessageQueue, 350); 99 | } 100 | 101 | form.addEventListener('focusin', (event) => { 102 | const id = event.composedPath().reduce((s, el) => s ? s : el.id, ''); 103 | if (id !== 'problem_body') { 104 | scheduleMessage({ 105 | type: 'webwork.interaction.focus', 106 | id: id.replace('mq-answer-', ''), 107 | frame: frame, 108 | }); 109 | } 110 | }); 111 | 112 | form.addEventListener('focusout', (event) => { 113 | const id = event.composedPath().reduce((s, el) => s ? s : el.id, ''); 114 | if (id !== 'problem_body') { 115 | scheduleMessage({ 116 | type: 'webwork.interaction.blur', 117 | id: id.replace('mq-answer-', ''), 118 | frame: frame, 119 | }); 120 | } 121 | }); 122 | 123 | const modal = document.getElementById('creditModal'); 124 | if (modal) { 125 | const bsModal = new bootstrap.Modal(modal); 126 | bsModal.show(); 127 | const creditForm = document.getElementById('creditForm'); 128 | creditForm.addEventListener('submit', (event) => { 129 | event.preventDefault(); 130 | const formData = new FormData(); 131 | 132 | // get the sessionJWT from the document and add it to the form data 133 | const sessionJWT = document.getElementsByName('sessionJWT').item(0).value; 134 | formData.append('sessionJWT', sessionJWT); 135 | // get the email from the form and add it to the form data 136 | const email = document.getElementById('creditModalEmail').value; 137 | formData.append('email', email); 138 | const url = creditForm.action; 139 | const options = { 140 | method: 'POST', 141 | body: formData, 142 | }; 143 | fetch(url, options) 144 | .then((response) => { 145 | if (!response.ok) { 146 | console.error(response.statusText); 147 | } 148 | bsModal.hide(); 149 | }) 150 | .catch((error) => { 151 | console.error('Error:', error); 152 | bsModal.hide(); 153 | }); 154 | }); 155 | 156 | // we also need to trigger the submit when the user clicks the button 157 | // or when they hit enter in the input field 158 | const creditButton = document.getElementById('creditModalSubmitBtn'); 159 | creditButton.addEventListener('click', (event) => { 160 | creditForm.dispatchEvent(new Event('submit')); 161 | }); 162 | const creditInput = document.getElementById('creditModalEmail'); 163 | creditInput.addEventListener('keyup', (event) => { 164 | if (event.key === 'Enter') { 165 | creditForm.dispatchEvent(new Event('submit')); 166 | } 167 | }); 168 | } 169 | })(); 170 | -------------------------------------------------------------------------------- /lib/WeBWorK/Localize/en.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: webwork2\n" 9 | "POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "PO-Revision-Date: 2021-03-09 17:00-0600\n" 11 | "Last-Translator: \n" 12 | "Language: en_US\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: 8bit\n" 16 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 17 | 18 | #. (wwRound(0, $answerScore*100) 19 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:255 20 | msgid "%1% correct" 21 | msgstr "" 22 | 23 | #. ($numBlanks) 24 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:395 25 | msgid "%quant(%1,of the questions remains,of the questions remain) unanswered." 26 | msgstr "" 27 | 28 | #: PG/macros/PGbasicmacros.pl:1277 PG/macros/PGbasicmacros.pl:1286 29 | msgid "" 30 | "(Instructor hint preview: show the student hint after the following number " 31 | "of attempts:" 32 | msgstr "" 33 | 34 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:386 35 | msgid "All of the answers above are correct." 36 | msgstr "" 37 | 38 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:384 39 | msgid "All of the gradeable answers above are correct." 40 | msgstr "" 41 | 42 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:289 43 | msgid "Answer Preview" 44 | msgstr "" 45 | 46 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:391 47 | msgid "At least one of the answers above is NOT correct." 48 | msgstr "" 49 | 50 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:291 51 | msgid "Correct Answer" 52 | msgstr "" 53 | 54 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:288 55 | msgid "Entered" 56 | msgstr "" 57 | 58 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:238 59 | msgid "FeedbackMessage" 60 | msgstr "" 61 | 62 | #: PG/macros/problemRandomize.pl:185 PG/macros/problemRandomize.pl:186 63 | msgid "Get a new version of this problem" 64 | msgstr "" 65 | 66 | #: PG/macros/compoundProblem.pl:470 67 | msgid "Go back to Part 1" 68 | msgstr "" 69 | 70 | #: PG/macros/compoundProblem.pl:291 PG/macros/compoundProblem.pl:480 71 | #: PG/macros/compoundProblem.pl:491 72 | msgid "Go on to next part" 73 | msgstr "" 74 | 75 | #: PG/macros/problemRandomize.pl:409 76 | msgid "Hardcopy will always print the original version of the problem." 77 | msgstr "" 78 | 79 | #: PG/macros/PGbasicmacros.pl:1558 80 | msgid "Hint:" 81 | msgstr "" 82 | 83 | #: PG/macros/PGbasicmacros.pl:1558 84 | msgid "Hint: " 85 | msgstr "" 86 | 87 | #: PG/macros/problemRandomize.pl:408 88 | msgid "If you come back to it later, it may revert to its original version." 89 | msgstr "" 90 | 91 | #: PG/macros/PGbasicmacros.pl:1226 92 | msgid "Instructor solution preview: show the student solution after due date." 93 | msgstr "" 94 | 95 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:292 96 | msgid "Message" 97 | msgstr "" 98 | 99 | #: PG/macros/problemRandomize.pl:382 PG/macros/problemRandomize.pl:407 100 | msgid "Note:" 101 | msgstr "" 102 | 103 | #: RenderApp/Controller/FormatRenderedProblem.pm:276 104 | msgid "Preview My Answers" 105 | msgstr "" 106 | 107 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:290 108 | msgid "Result" 109 | msgstr "" 110 | 111 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:302 112 | msgid "Results for this submission" 113 | msgstr "" 114 | 115 | #: PG/macros/problemRandomize.pl:187 116 | msgid "Set random seed to:" 117 | msgstr "" 118 | 119 | #: RenderApp/Controller/FormatRenderedProblem.pm:277 120 | msgid "Show correct answers" 121 | msgstr "" 122 | 123 | #: PG/macros/PGbasicmacros.pl:1554 PG/macros/PGbasicmacros.pl:1555 124 | msgid "Solution:" 125 | msgstr "" 126 | 127 | #: PG/macros/PGbasicmacros.pl:1553 128 | msgid "Solution: " 129 | msgstr "" 130 | 131 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:377 132 | msgid "Some answers will be graded later." 133 | msgstr "" 134 | 135 | #: RenderApp/Controller/FormatRenderedProblem.pm:278 136 | msgid "Submit Answers" 137 | msgstr "" 138 | 139 | #: PG/macros/compoundProblem.pl:502 140 | msgid "Submit your answers again to go on to the next part." 141 | msgstr "" 142 | 143 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:379 144 | msgid "The answer above is NOT correct." 145 | msgstr "" 146 | 147 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:375 148 | msgid "The answer above is correct." 149 | msgstr "" 150 | 151 | #: PG/macros/problemRandomize.pl:407 152 | msgid "This is a new (re-randomized) version of the problem." 153 | msgstr "" 154 | 155 | #: PG/macros/PGbasicmacros.pl:3084 156 | msgid "This problem contains a video which must be viewed online." 157 | msgstr "" 158 | 159 | #: PG/macros/compoundProblem.pl:602 160 | msgid "This problem has more than one part." 161 | msgstr "" 162 | 163 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:248 164 | msgid "Ungraded" 165 | msgstr "" 166 | 167 | #: PG/macros/PGanswermacros.pl:1693 168 | msgid "You can earn partial credit on this problem." 169 | msgstr "" 170 | 171 | #: PG/macros/problemRandomize.pl:383 172 | msgid "You can get a new version of this problem after the due date." 173 | msgstr "" 174 | 175 | #: PG/macros/compoundProblem.pl:610 176 | msgid "You may not change your answers when going on to the next part!" 177 | msgstr "" 178 | 179 | #: PG/macros/PGbasicmacros.pl:3079 180 | msgid "Your browser does not support the video tag." 181 | msgstr "" 182 | 183 | #: PG/macros/compoundProblem.pl:603 184 | msgid "Your score for this attempt is for this part only;" 185 | msgstr "" 186 | 187 | #. (wwRound(0, $problemResult->{score} * 100) 188 | #: RenderApp/Controller/FormatRenderedProblem.pm:215 189 | msgid "Your score on this attempt is %1" 190 | msgstr "" 191 | 192 | #: RenderApp/Controller/FormatRenderedProblem.pm:217 193 | msgid "Your score was not recorded." 194 | msgstr "" 195 | 196 | #: PG/macros/PGbasicmacros.pl:645 PG/macros/PGbasicmacros.pl:656 197 | msgid "answer" 198 | msgstr "" 199 | 200 | #: PG/macros/PGbasicmacros.pl:669 201 | msgid "column" 202 | msgstr "" 203 | 204 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:244 205 | msgid "correct" 206 | msgstr "" 207 | 208 | #. ('j','k','_0') 209 | #. ('j','k') 210 | #: PG/lib/Parser/List/Vector.pm:35 PG/lib/Value/Vector.pm:278 211 | #: PG/macros/contextLimitedVector.pl:94 212 | msgid "i" 213 | msgstr "" 214 | 215 | #: PG/macros/contextPiecewiseFunction.pl:774 216 | msgid "if" 217 | msgstr "" 218 | 219 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:253 220 | msgid "incorrect" 221 | msgstr "" 222 | 223 | #: PG/macros/contextPiecewiseFunction.pl:777 224 | msgid "otherwise" 225 | msgstr "" 226 | 227 | #: PG/macros/PGbasicmacros.pl:662 228 | msgid "part" 229 | msgstr "" 230 | 231 | #: PG/macros/PGbasicmacros.pl:651 232 | msgid "problem" 233 | msgstr "" 234 | 235 | #: PG/macros/PGbasicmacros.pl:668 236 | msgid "row" 237 | msgstr "" 238 | 239 | #: PG/macros/compoundProblem.pl:470 PG/macros/compoundProblem.pl:480 240 | msgid "when you submit your answers" 241 | msgstr "" 242 | 243 | #: PG/macros/compoundProblem.pl:604 244 | msgid "your overall score is for all the parts combined." 245 | msgstr "" 246 | -------------------------------------------------------------------------------- /lib/WeBWorK/Localize/standalone.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "POT-Creation-Date: YEAR-MO-DA HO:MI+ZONE\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=CHARSET\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | 18 | #. (wwRound(0, $answerScore*100) 19 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:255 20 | msgid "%1% correct" 21 | msgstr "" 22 | 23 | #. ($numBlanks) 24 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:395 25 | msgid "%quant(%1,of the questions remains,of the questions remain) unanswered." 26 | msgstr "" 27 | 28 | #: PG/macros/PGbasicmacros.pl:1277 PG/macros/PGbasicmacros.pl:1286 29 | msgid "(Instructor hint preview: show the student hint after the following number of attempts:" 30 | msgstr "" 31 | 32 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:386 33 | msgid "All of the answers above are correct." 34 | msgstr "" 35 | 36 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:384 37 | msgid "All of the gradeable answers above are correct." 38 | msgstr "" 39 | 40 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:289 41 | msgid "Answer Preview" 42 | msgstr "" 43 | 44 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:391 45 | msgid "At least one of the answers above is NOT correct." 46 | msgstr "" 47 | 48 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:291 49 | msgid "Correct Answer" 50 | msgstr "" 51 | 52 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:288 53 | msgid "Entered" 54 | msgstr "" 55 | 56 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:238 57 | msgid "FeedbackMessage" 58 | msgstr "" 59 | 60 | #: PG/macros/problemRandomize.pl:185 PG/macros/problemRandomize.pl:186 61 | msgid "Get a new version of this problem" 62 | msgstr "" 63 | 64 | #: PG/macros/compoundProblem.pl:470 65 | msgid "Go back to Part 1" 66 | msgstr "" 67 | 68 | #: PG/macros/compoundProblem.pl:291 PG/macros/compoundProblem.pl:480 PG/macros/compoundProblem.pl:491 69 | msgid "Go on to next part" 70 | msgstr "" 71 | 72 | #: PG/macros/problemRandomize.pl:409 73 | msgid "Hardcopy will always print the original version of the problem." 74 | msgstr "" 75 | 76 | #: PG/macros/PGbasicmacros.pl:1558 77 | msgid "Hint:" 78 | msgstr "" 79 | 80 | #: PG/macros/PGbasicmacros.pl:1558 81 | msgid "Hint: " 82 | msgstr "" 83 | 84 | #: PG/macros/problemRandomize.pl:408 85 | msgid "If you come back to it later, it may revert to its original version." 86 | msgstr "" 87 | 88 | #: PG/macros/PGbasicmacros.pl:1226 89 | msgid "Instructor solution preview: show the student solution after due date." 90 | msgstr "" 91 | 92 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:292 93 | msgid "Message" 94 | msgstr "" 95 | 96 | #: PG/macros/problemRandomize.pl:382 PG/macros/problemRandomize.pl:407 97 | msgid "Note:" 98 | msgstr "" 99 | 100 | #: RenderApp/Controller/FormatRenderedProblem.pm:276 101 | msgid "Preview My Answers" 102 | msgstr "" 103 | 104 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:290 105 | msgid "Result" 106 | msgstr "" 107 | 108 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:302 109 | msgid "Results for this submission" 110 | msgstr "" 111 | 112 | #: PG/macros/problemRandomize.pl:187 113 | msgid "Set random seed to:" 114 | msgstr "" 115 | 116 | #: RenderApp/Controller/FormatRenderedProblem.pm:277 117 | msgid "Show correct answers" 118 | msgstr "" 119 | 120 | #: PG/macros/PGbasicmacros.pl:1554 PG/macros/PGbasicmacros.pl:1555 121 | msgid "Solution:" 122 | msgstr "" 123 | 124 | #: PG/macros/PGbasicmacros.pl:1553 125 | msgid "Solution: " 126 | msgstr "" 127 | 128 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:377 129 | msgid "Some answers will be graded later." 130 | msgstr "" 131 | 132 | #: RenderApp/Controller/FormatRenderedProblem.pm:278 133 | msgid "Submit Answers" 134 | msgstr "" 135 | 136 | #: PG/macros/compoundProblem.pl:502 137 | msgid "Submit your answers again to go on to the next part." 138 | msgstr "" 139 | 140 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:379 141 | msgid "The answer above is NOT correct." 142 | msgstr "" 143 | 144 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:375 145 | msgid "The answer above is correct." 146 | msgstr "" 147 | 148 | #: PG/macros/problemRandomize.pl:407 149 | msgid "This is a new (re-randomized) version of the problem." 150 | msgstr "" 151 | 152 | #: PG/macros/PGbasicmacros.pl:3084 153 | msgid "This problem contains a video which must be viewed online." 154 | msgstr "" 155 | 156 | #: PG/macros/compoundProblem.pl:602 157 | msgid "This problem has more than one part." 158 | msgstr "" 159 | 160 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:248 161 | msgid "Ungraded" 162 | msgstr "" 163 | 164 | #: PG/macros/PGanswermacros.pl:1693 165 | msgid "You can earn partial credit on this problem." 166 | msgstr "" 167 | 168 | #: PG/macros/problemRandomize.pl:383 169 | msgid "You can get a new version of this problem after the due date." 170 | msgstr "" 171 | 172 | #: PG/macros/compoundProblem.pl:610 173 | msgid "You may not change your answers when going on to the next part!" 174 | msgstr "" 175 | 176 | #: PG/macros/PGbasicmacros.pl:3079 177 | msgid "Your browser does not support the video tag." 178 | msgstr "" 179 | 180 | #: PG/macros/compoundProblem.pl:603 181 | msgid "Your score for this attempt is for this part only;" 182 | msgstr "" 183 | 184 | #. (wwRound(0, $problemResult->{score} * 100) 185 | #: RenderApp/Controller/FormatRenderedProblem.pm:215 186 | msgid "Your score on this attempt is %1" 187 | msgstr "" 188 | 189 | #: RenderApp/Controller/FormatRenderedProblem.pm:217 190 | msgid "Your score was not recorded." 191 | msgstr "" 192 | 193 | #: PG/macros/PGbasicmacros.pl:645 PG/macros/PGbasicmacros.pl:656 194 | msgid "answer" 195 | msgstr "" 196 | 197 | #: PG/macros/PGbasicmacros.pl:669 198 | msgid "column" 199 | msgstr "" 200 | 201 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:244 202 | msgid "correct" 203 | msgstr "" 204 | 205 | #. ('j','k','_0') 206 | #. ('j','k') 207 | #: PG/lib/Parser/List/Vector.pm:35 PG/lib/Value/Vector.pm:278 PG/macros/contextLimitedVector.pl:94 208 | msgid "i" 209 | msgstr "" 210 | 211 | #: PG/macros/contextPiecewiseFunction.pl:774 212 | msgid "if" 213 | msgstr "" 214 | 215 | #: WeBWorK/lib/WeBWorK/Utils/AttemptsTable.pm:253 216 | msgid "incorrect" 217 | msgstr "" 218 | 219 | #: PG/macros/contextPiecewiseFunction.pl:777 220 | msgid "otherwise" 221 | msgstr "" 222 | 223 | #: PG/macros/PGbasicmacros.pl:662 224 | msgid "part" 225 | msgstr "" 226 | 227 | #: PG/macros/PGbasicmacros.pl:651 228 | msgid "problem" 229 | msgstr "" 230 | 231 | #: PG/macros/PGbasicmacros.pl:668 232 | msgid "row" 233 | msgstr "" 234 | 235 | #: PG/macros/compoundProblem.pl:470 PG/macros/compoundProblem.pl:480 236 | msgid "when you submit your answers" 237 | msgstr "" 238 | 239 | #: PG/macros/compoundProblem.pl:604 240 | msgid "your overall score is for all the parts combined." 241 | msgstr "" 242 | -------------------------------------------------------------------------------- /public/js/filebrowser.js: -------------------------------------------------------------------------------- 1 | // pass the form to use for updating and a callback for updating back-navigation 2 | // examples for this callback are provided: `diveIn` and `backOut` 3 | function updateBrowser(formId, updateBackNav) { 4 | var form = window.document.getElementById(formId); 5 | var target = form.action; 6 | var select = form.getElementsByTagName('select')[0]; // each form has only one file.ext1 file.ext2 120 | latexImageConvertOptions: 121 | input: 122 | density: 300 123 | output: 124 | quality: 100 125 | 126 | # Strings to insert at the start and end of the body of a problem. 127 | problemPreamble: 128 | TeX: '' 129 | HTML: '' 130 | problemPostamble: 131 | TeX: '' 132 | HTML: '' 133 | 134 | # Math entry assistance 135 | entryAssist: MathQuill 136 | 137 | # Whether to use javascript for rendering Live3D graphs. 138 | use_javascript_for_live3d: 1 139 | 140 | # Size in pixels of dynamically-generated images, i.e. graphs. 141 | onTheFlyImageSize: 400 142 | 143 | # Locations of CAPA resources. (Only necessary if you need to use converted CAPA problems.) 144 | CAPA_Tools: $Contrib_dir/CAPA/macros/CAPA_Tools/ 145 | CAPA_MCTools: $Contrib_dir/Contrib/CAPA/macros/CAPA_MCTools/ 146 | CAPA_GraphicsDirectory: $Contrib_dir/Contrib/CAPA/CAPA_Graphics/ 147 | CAPA_Graphics_URL: $pg_root_url/CAPA_Graphics/ 148 | 149 | # Answer evaluatior defaults 150 | ansEvalDefaults: 151 | functAbsTolDefault: 0.001 152 | functLLimitDefault: 0.0000001 153 | functMaxConstantOfIntegration: 1E8 154 | functNumOfPoints: 3 155 | functRelPercentTolDefault: 0.1 156 | functULimitDefault: 0.9999999 157 | functVarDefault: x 158 | functZeroLevelDefault: 1E-14 159 | functZeroLevelTolDefault: 1E-12 160 | numAbsTolDefault: 0.001 161 | numFormatDefault: '' 162 | numRelPercentTolDefault: 0.1 163 | numZeroLevelDefault: 1E-14 164 | numZeroLevelTolDefault: 1E-12 165 | useBaseTenLog: 0 166 | defaultDisplayMatrixStyle: '[s]' # left delimiter, middle line delimiters, right delimiter 167 | 168 | options: 169 | # The default grader to use, if a problem doesn't specify. 170 | grader: avg_problem_grader 171 | 172 | # Note that the first of useMathQuill and useMathView that is set (in that order) to 1 will be used. 173 | 174 | # Set to 1 use MathQuill in answer boxes. 175 | useMathQuill: 1 176 | 177 | # Set to 1 to use the MathView preview system with answer boxes. 178 | useMathView: 0 179 | 180 | # This is the operations file to use for mathview, each contains a different locale. 181 | mathViewLocale: mv_locale_us.js 182 | 183 | # Catch translation warnings internally. 184 | catchWarnings: 1 185 | 186 | # "images" mode has several settings: 187 | displayModeOptions: 188 | images: 189 | # Determines the method used to align images in output. Can be any valid value for the css vertical-align rule such 190 | # as 'baseline' or 'middle'. 191 | dvipng_align: baseline 192 | 193 | # If dbsource is set to a nonempty value, then this database connection information will be used to store depths. 194 | # It is assumed that the 'depths' table exists in the database. 195 | dvipng_depth_db: 196 | dbsource: '' 197 | user: '' 198 | passwd: '' 199 | 200 | # PG modules to load 201 | # The first item of each list is the module file to load. The remaining items are additional packages to import that are 202 | # also contained in that file. 203 | # That is, if you wish to include a file MyModule.pm which containes the package MyModule and the additional packages 204 | # Dependency1 and Dependency2, then these should appear as [Mymodule, Dependency1, Dependency2]. 205 | modules: 206 | - [Encode] 207 | - ['Encode::Encoding'] 208 | - ['HTML::Parser'] 209 | - ['HTML::Entities'] 210 | - [DynaLoader] 211 | - [Exporter] 212 | - [GD] 213 | - [utf8] 214 | - [AlgParser, AlgParserWithImplicitExpand, Expr, ExprWithImplicitExpand] 215 | - [AnswerHash, AnswerEvaluator] 216 | - [LaTeXImage] 217 | - [WWPlot] # required by Circle (and others) 218 | - [Circle] 219 | - ['Class::Accessor'] 220 | - [Complex] 221 | - [Complex1] 222 | - [Distributions] 223 | - [Fraction] 224 | - [Fun] 225 | - [Hermite] 226 | - [Label] 227 | - [ChoiceList] 228 | - [Match] 229 | - [MatrixReal1] # required by Matrix 230 | - [Matrix] 231 | - [Multiple] 232 | - [PGrandom] 233 | - [Regression] 234 | - [Select] 235 | - [Units] 236 | - [VectorField] 237 | - [Parser] 238 | - [Value] 239 | - ['Parser::Legacy'] 240 | - [Statistics] 241 | - [Chromatic] # for Northern Arizona graph problems 242 | - [Applet] 243 | - [PGcore] 244 | - [PGalias] 245 | - [PGresource] 246 | - [PGloadfiles] 247 | - [PGanswergroup] 248 | - [PGresponsegroup] 249 | - ['Tie::IxHash'] 250 | - ['Locale::Maketext'] 251 | - ['WeBWorK::PG::Localize'] 252 | - [JSON] 253 | - ['Class::Tiny'] 254 | - ['IO::Handle'] 255 | - ['Rserve'] 256 | - [DragNDrop] 257 | - ['Types::Serialiser'] 258 | - [strict] 259 | -------------------------------------------------------------------------------- /public/images/webwork_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 27 | 33 | 34 | 41 | 47 | 48 | 55 | 61 | 62 | 69 | 75 | 76 | 77 | 103 | 105 | 106 | 108 | image/svg+xml 109 | 111 | 112 | 113 | 114 | 115 | 120 | 127 | 134 | 136 | 139 | 142 | 144 | 146 | 148 | 156 | 158 | 164 | 170 | 175 | 181 | 187 | 193 | 199 | 200 | 201 | 202 | 203 | WeBWorK 215 | 216 | 217 | 218 | 219 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WeBWorK Standalone Problem Renderer & Editor 2 | 3 | ![Commit Activity](https://img.shields.io/github/commit-activity/m/openwebwork/renderer?style=plastic) 4 | ![License](https://img.shields.io/github/license/openwebwork/renderer?style=plastic) 5 | 6 | This is a PG Renderer derived from the WeBWorK2 codebase 7 | 8 | * [https://github.com/openwebwork/webwork2](https://github.com/openwebwork/webwork2) 9 | 10 | ## DOCKER CONTAINER INSTALL 11 | 12 | ```bash 13 | mkdir volumes 14 | mkdir container 15 | git clone https://github.com/openwebwork/webwork-open-problem-library volumes/webwork-open-problem-library 16 | git clone --recursive https://github.com/openwebwork/renderer container/ 17 | docker build --tag renderer:1.0 ./container 18 | 19 | docker run -d \ 20 | --rm \ 21 | --name standalone-renderer \ 22 | --publish 3000:3000 \ 23 | --mount type=bind,source="$(pwd)"/volumes/webwork-open-problem-library/,target=/usr/app/webwork-open-problem-library \ 24 | --env MOJO_MODE=development \ 25 | renderer:1.0 26 | ``` 27 | 28 | If you have non-OPL content, it can be mounted as a volume at `/usr/app/private` by adding the following line to the 29 | `docker run` command: 30 | 31 | ```bash 32 | --mount type=bind,source=/pathToYourLocalContentRoot,target=/usr/app/private \ 33 | ``` 34 | 35 | A default configuration file is included in the container, but it can be overridden by mounting a replacement at the 36 | application root. This is necessary if, for example, you want to run the container in `production` mode. 37 | 38 | ```bash 39 | --mount type=bind,source=/pathToYour/render_app.conf,target=/usr/app/render_app.conf \ 40 | ``` 41 | 42 | ## LOCAL INSTALL 43 | 44 | If using a local install instead of docker: 45 | 46 | * Clone the renderer and its submodules: `git clone --recursive https://github.com/openwebwork/renderer` 47 | * Enter the project directory: `cd renderer` 48 | * Install Perl dependencies listed in Dockerfile (CPANMinus recommended) 49 | * clone webwork-open-problem-library into the provided stub ./webwork-open-problem-library 50 | * `git clone https://github.com/openwebwork/webwork-open-problem-library ./webwork-open-problem-library` 51 | * copy `render_app.conf.dist` to `render_app.conf` and make any desired modifications 52 | * copy `conf/pg_config.yml` to `lib/PG/pg_config.yml` and make any desired modifications 53 | * install third party JavaScript dependencies 54 | * `cd public/` 55 | * `npm ci` 56 | * `cd ..` 57 | * install PG JavaScript dependencies 58 | * `cd lib/PG/htdocs` 59 | * `npm ci` 60 | * start the app with `morbo ./script/render_app` or `morbo -l http://localhost:3000 ./script/render_app` if changing 61 | root url 62 | * access on `localhost:3000` by default or otherwise specified root url 63 | 64 | ## Editor Interface 65 | 66 | * point your browser at [`localhost:3000`](http://localhost:3000/) 67 | * select an output format (see below) 68 | * specify a problem path (e.g. `Library/Rochester/setMAAtutorial/hello.pg`) and a problem seed (e.g. `1234`) 69 | * click on "Load" to load the problem source into the editor 70 | * render the contents of the editor (with or without edits) via "Render contents of editor" 71 | * click on "Save" to save your edits to the specified file path 72 | 73 | ![image](https://user-images.githubusercontent.com/3385756/129100124-72270558-376d-4265-afe2-73b5c9a829af.png) 74 | 75 | ## Server Configuration 76 | 77 | Modification of `baseURL` may be necessary to separate multiple services running on `SITE_HOST`, and will be used to extend `SITE_HOST`. The result of this extension will serve as the root URL for accessing the renderer (and any supplementary assets it may need to provide in support of a rendered problem). If `baseURL` is an absolute URL, it will be used verbatim -- userful if the renderer is running behind a load balancer. 78 | 79 | By default, `formURL` will further extend `baseURL`, and serve as the form-data target for user interactions with problems rendered by this service. If `formURL` is an absolute URL, it will be used verbatim -- useful if your implementation intends to sit in between the user and the renderer. 80 | 81 | ## Renderer API 82 | 83 | Can be accessed by POST to `{SITE_HOST}{baseURL}{formURL}`. 84 | 85 | By default, `localhost:3000/render-api`. 86 | 87 | ### **REQUIRED PARAMETERS** 88 | 89 | The bare minimum of parameters that must be included are: 90 | * the code for the problem, so, **ONE** of the following (in order of precedence): 91 | * `problemSource` (raw pg source code, _can_ be base64 encoded) 92 | * `sourceFilePath` (relative to OPL `Library/`, `Contrib/`; or in `private/`) 93 | * `problemSourceURL` (fetch the pg source from remote server) 94 | * a "seed" value for consistent randomization 95 | * `problemSeed` (integer) 96 | 97 | | Key | Type | Description | Notes | 98 | | --- | ---- | ----------- | ----- | 99 | | problemSource | string (possibly base64 encoded) | The source code of a problem to be rendered | Takes precedence over `sourceFilePath`. | 100 | | sourceFilePath | string | The path to the file that contains the problem source code | Renderer will automatically adjust `Library/` and `Contrib/` relative to the webwork-open-problem-library root. Path may also begin with `private/` for local, non-OPL content. | 101 | | problemSourceURL | string | The URL from which to fetch the problem source code | Takes precedence over `problemSource` and `sourceFilePath`. A request to this URL is expected to return valid pg source code in base64 encoding. | 102 | | problemSeed | number | The seed that determines the randomization of a problem | | 103 | 104 | **ALL** other request parameters are optional. 105 | 106 | ### Infrastructure Parameters 107 | 108 | The defaults for these parameters are set in `render_app.conf`, but these can be overridden on a per-request basis. 109 | 110 | | Key | Type | Default Value | Description | Notes | 111 | | --- | ---- | ------------- | ----------- | ----- | 112 | | baseURL | string | '/' (as set in `render_app.conf`) | the URL for relative paths | | 113 | | formURL | string | '/render-api' (as set in `render_app.conf`) | the URL for form submission | | 114 | 115 | ### Display Parameters 116 | 117 | #### Formatting 118 | 119 | Parameters that control the structure and templating of the response. 120 | 121 | | Key | Type | Default Value | Description | Notes | 122 | | --- | ---- | ------------- | ----------- | ----- | 123 | | language | string | en | Language to render the problem in (if supported) | affects the translation of template strings, _not_ actual problem content | 124 | | _format | string | 'html' | Determine how the response is _structured_ ('html' or 'json') | usually 'html' if the user is directly interacting with the renderer, 'json' if your CMS sits between user and renderer | 125 | | outputFormat | string | 'default' | Determines how the problem should be formatted | 'default', 'static', 'PTX', 'raw', or | 126 | | displayMode | string | 'MathJax' | How to prepare math content for display | 'MathJax' or 'ptx' | 127 | 128 | #### User Interactions 129 | 130 | Control how the user is allowed to interact with the rendered problem. 131 | 132 | Requesting `outputFormat: 'static'` will prevent any buttons from being included in the rendered output, regardless of the following options. 133 | 134 | | Key | Type | Default Value | Description | Notes | 135 | | --- | ---- | ------------- | ----------- | ----- | 136 | | hidePreviewButton | number (boolean) | false | "Preview My Answers" is enabled by default | | 137 | | hideCheckAnswersButton | number (boolean) | false | "Submit Answers" is enabled by default | | 138 | | showCorrectAnswersButton | number (boolean) | `isInstructor` | "Show Correct Answers" is disabled by default, enabled if `isInstructor` is true (see below) | | 139 | 140 | #### Content 141 | 142 | Control what is shown to the user: hints, solutions, attempt results, scores, etc. 143 | 144 | | Key | Type | Default Value | Description | Notes | 145 | | --- | ---- | ------------- | ----------- | ----- | 146 | | permissionLevel | number | 0 | **DEPRECATED.** Use `isInstructor` instead. | 147 | | isInstructor | number (boolean) | 0 | Is the user viewing the problem an instructor or not. | Used by PG to determine if scaffolds can be allowed to be open among other things | 148 | | showHints | number (boolean) | 1 | Whether or not to show hints | | 149 | | showSolutions | number (boolean) | `isInstructor` | Whether or not to show the solutions | | 150 | | hideAttemptsTable | number (boolean) | 0 | Hide the table of answer previews/results/messages | If you have a replacement for flagging the submitted entries as correct/incorrect | 151 | | showSummary | number (boolean) | 1 | Determines whether or not to show a summary of the attempt underneath the table | Only relevant if the Attempts Table is shown `hideAttemptsTable: false` (default) | 152 | | showComments | number (boolean) | 0 | Renders author comment field at the end of the problem | | 153 | | showFooter | number (boolean) | 0 | Show version information and WeBWorK copyright footer | | 154 | | includeTags | number (boolean) | 0 | Includes problem tags in the returned JSON | Only relevant when requesting `_format: 'json'` | 155 | 156 | ## Using JWTs 157 | 158 | There are three JWT structures that the Renderer uses, each containing its predecessor: 159 | * problemJWT 160 | * sessionJWT 161 | * answerJWT 162 | 163 | ### ProblemJWT 164 | 165 | This JWT encapsulates the request parameters described above, under the API heading. Any value set in the JWT cannot be overridden by form-data. For example, if the problemJWT includes `isInstructor: 0`, then any subsequent interaction with the problem rendered by this JWT cannot override this setting by including `isInstructor: 1` in the form-data. 166 | 167 | ### SessionJWT 168 | 169 | This JWT encapsulates a user's attempt on a problem, including: 170 | * the text and LaTeX versions of each answer entry 171 | * count of incorrect attempts (stopping after a correct attempt, or after `showCorrectAnswers` is used) 172 | * the problemJWT 173 | 174 | If stored (see next), this JWT can be submitted as the sole request parameter, and the response will effectively restore the users current state of interaction with the problem (as of their last submission). 175 | 176 | ### AnswerJWT 177 | 178 | If the initial problemJWT contains a value for `JWTanswerURL`, this JWT will be generated and sent to the specified URL. The answerJWT is the only content provided to the URL. The renderer is intended to to be user-agnostic. It is recommended that the JWTanswerURL specify the unique identifier for the user/problem combination. (e.g. `JWTanswerURL: 'https://db.yoursite.org/grades-api/:user_problem_id'`) 179 | 180 | For security purposes, this parameter is only accepted when included as part of a JWT. 181 | 182 | This JWT encapsulates the status of the user's interaction with the problem. 183 | * score 184 | * sessionJWT 185 | 186 | The goal here is to update the `JWTanswerURL` with the score and "state" for the user. If you have uses for additional information, please feel free to suggest as a GitHub Issue. 187 | -------------------------------------------------------------------------------- /lib/RenderApp/Controller/Render.pm: -------------------------------------------------------------------------------- 1 | package RenderApp::Controller::Render; 2 | use Mojo::Base 'Mojolicious::Controller', -async_await; 3 | 4 | use Mojo::JSON qw(encode_json decode_json); 5 | use Crypt::JWT qw(encode_jwt decode_jwt); 6 | use Time::HiRes qw/time/; 7 | 8 | use WeBWorK::PreTeXt; 9 | 10 | sub parseRequest { 11 | my $c = shift; 12 | my %params = %{ $c->req->params->to_hash }; 13 | 14 | my $originIP = $c->req->headers->header('X-Forwarded-For') 15 | // '' =~ s!^\s*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}).*$!$1!r; 16 | $originIP ||= $c->tx->remote_address || 'unknown-origin'; 17 | 18 | if ($ENV{STRICT_JWT} && !(defined $params{problemJWT} || defined $params{sessionJWT})) { 19 | return $c->exception('Not allowed to request problems with raw data.', 403); 20 | } 21 | 22 | # protect against DOM manipulation 23 | if (defined $params{submitAnswers} && defined $params{previewAnswers}) { 24 | $c->log->error('Simultaneous submit and preview! JWT: ', $params{problemJWT} // {}); 25 | return $c->exception('Malformed request.', 400); 26 | } 27 | 28 | # TODO: ensure showCorrectAnswers does not appear without showCorrectAnswersButton 29 | # showCorrectAnswersButton cannot be checked until after pulling in problemJWT 30 | 31 | # ensure that these params are only provided by trusted source 32 | for (qw(JWTanswerURL sessionID numCorrect numIncorrect)) { 33 | delete $params{$_}; 34 | } 35 | 36 | # set session-specific info (previous attempts, correct/incorrect count) 37 | if (defined $params{sessionJWT}) { 38 | $c->log->info("Received JWT: using sessionJWT"); 39 | my $sessionJWT = $params{sessionJWT}; 40 | my $claims; 41 | eval { 42 | $claims = decode_jwt( 43 | token => $sessionJWT, 44 | key => $ENV{webworkJWTsecret}, 45 | verify_iss => $ENV{SITE_HOST}, 46 | ); 47 | 1; 48 | } or do { 49 | return $c->croak($@, 3); 50 | }; 51 | # only supply key-values that are not already provided 52 | # e.g. current responses vs. previously submitted responses 53 | # except for problemJWT which must remain consistent with session 54 | delete $params{problemJWT}; 55 | foreach my $key (keys %$claims) { 56 | $params{$key} //= $claims->{$key}; 57 | } 58 | } 59 | 60 | # problemJWT sets basic problem request configuration and rendering options 61 | if (defined $params{problemJWT}) { 62 | $c->log->info("Received JWT: using problemJWT"); 63 | my $problemJWT = $params{problemJWT}; 64 | my $claims; 65 | eval { 66 | $claims = decode_jwt( 67 | token => $problemJWT, 68 | key => $ENV{problemJWTsecret}, 69 | verify_aud => $ENV{SITE_HOST}, 70 | ); 71 | 1; 72 | } or do { 73 | return $c->croak($@, 3); 74 | }; 75 | # LibreTexts uses provider name as key for problemJWT claims 76 | $claims = $claims->{webwork} if defined $claims->{webwork}; 77 | # override key-values in params with those provided in the JWT 78 | @params{ keys %$claims } = values %$claims; 79 | } elsif ($params{outputFormat} ne 'ptx') { 80 | # if no JWT is provided, create one (unless this is a pretext request) 81 | $params{aud} = $ENV{SITE_HOST}; 82 | $params{isInstructor} //= 0; 83 | $params{sessionID} ||= time; 84 | my $req_jwt = encode_jwt( 85 | payload => \%params, 86 | key => $ENV{problemJWTsecret}, 87 | alg => 'PBES2-HS512+A256KW', 88 | enc => 'A256GCM', 89 | auto_iat => 1 90 | ); 91 | $params{problemJWT} = $req_jwt; 92 | } 93 | $params{originIP} = $originIP if $originIP; 94 | return \%params; 95 | } 96 | 97 | sub fetchRemoteSource_p { 98 | my $c = shift; 99 | my $url = shift; 100 | 101 | # tell the library who originated the request for pg source 102 | my $req_origin = $c->req->headers->origin || 'no origin'; 103 | my $req_referrer = $c->req->headers->referrer || 'no referrer'; 104 | my $header = { 105 | Accept => 'application/json;charset=utf-8', 106 | Requester => $req_origin, 107 | Referrer => $req_referrer, 108 | }; 109 | 110 | return $c->ua->max_redirects(5)->request_timeout(10)->get_p($url => $header)->then(sub { 111 | my $tx = shift; 112 | my $res = $tx->result; 113 | unless ($res->is_success) { 114 | $c->log->error("fetchRemoteSource: Request to $url failed with error - " . $res->message); 115 | return; 116 | } 117 | # library responses are JSON formatted with expected 'raw_source' 118 | my $obj; 119 | eval { $obj = decode_json($res->body); 1; } or do { 120 | $c->log->error('fetchRemoteSource: Failed to parse JSON', $res->body); 121 | return $c->croak($@, 3); 122 | }; 123 | return ($obj && $obj->{raw_source}) ? $obj->{raw_source} : undef; 124 | })->catch(sub { 125 | my $err = shift; 126 | $c->stash(message => $err); 127 | $c->log->error("Problem source: Request to $url failed with error - $err"); 128 | return; 129 | }); 130 | } 131 | 132 | async sub problem { 133 | my $c = shift; 134 | my $inputs_ref = $c->parseRequest; 135 | return unless $inputs_ref; 136 | 137 | $inputs_ref->{problemSource} = fetchRemoteSource_p($c, $inputs_ref->{problemSourceURL}) 138 | if $inputs_ref->{problemSourceURL}; 139 | 140 | my $file_path = $inputs_ref->{sourceFilePath}; 141 | my $random_seed = $inputs_ref->{problemSeed}; 142 | 143 | my $problem_contents; 144 | if ($inputs_ref->{problemSource} && $inputs_ref->{problemSource} =~ /Mojo::Promise/) { 145 | $problem_contents = await $inputs_ref->{problemSource}; 146 | $file_path = $inputs_ref->{problemSourceURL}; 147 | if ($problem_contents) { 148 | $c->log->info("Problem source fetched from $inputs_ref->{problemSourceURL}"); 149 | # $c->stash($problem_contents->{filename} => $problem_contents->{url}); 150 | # $problem_contents = $problem_contents->{raw_source}; 151 | } else { 152 | return $c->exception('Failed to retrieve problem source.', 500); 153 | } 154 | } else { 155 | $problem_contents = $inputs_ref->{problemSource}; 156 | } 157 | 158 | my $problem = $c->newProblem({ 159 | log => $c->log, 160 | read_path => $file_path, 161 | random_seed => $random_seed, 162 | problem_contents => $problem_contents 163 | }); 164 | unless ($problem->success()) { 165 | return $c->exception($problem->{_message}, $problem->{status}); 166 | } 167 | 168 | $c->render_later; # tell Mojo that this might take a while 169 | my $ww_return_json; 170 | { 171 | $ww_return_json = await $problem->render($inputs_ref); 172 | 173 | unless ($problem->success()) { 174 | return $c->exception($problem->{_message}, $problem->{status}); 175 | } 176 | } 177 | 178 | my $return_object; 179 | eval { $return_object = decode_json($ww_return_json); 1; } or do { 180 | $c->log->error('problem.render: Failed to parse JSON', $ww_return_json); 181 | return $c->croak($@, 3); 182 | }; 183 | $return_object->{inputs_ref} = $inputs_ref; 184 | 185 | # if answerURL provided and this is a submit, then send the answerJWT 186 | if ($inputs_ref->{JWTanswerURL} && $inputs_ref->{submitAnswers} && !$inputs_ref->{isLocked}) { 187 | # can this be 'await'ed later? 188 | $return_object->{JWTanswerURLstatus} = 189 | await sendAnswerJWT($c, $inputs_ref->{JWTanswerURL}, $return_object->{answerJWT}); 190 | } 191 | 192 | # log interaction and format the response 193 | if ($c->app->config('INTERACTION_LOG')) { 194 | my $displayScore = $inputs_ref->{previewAnswers} ? 'preview' : $return_object->{problem_result}{score}; 195 | $displayScore .= '*' if $inputs_ref->{showCorrectAnswers}; 196 | $displayScore //= 'err'; 197 | 198 | $c->logAttempt( 199 | $inputs_ref->{sessionID}, 200 | $inputs_ref->{originIP}, 201 | $inputs_ref->{isInstructor} ? 'instructor' : 'student', 202 | $inputs_ref->{answersSubmitted} ? $displayScore : 'init', 203 | $inputs_ref->{problemSeed}, 204 | $inputs_ref->{sourceFilePath} || $inputs_ref->{problemSourceURL} || $inputs_ref->{problemSource}, 205 | $inputs_ref->{essay} ? '"' . $inputs_ref->{essay} =~ s/"/\\"/gr . '"' : '""', 206 | ); 207 | } 208 | 209 | return $c->format($return_object); 210 | } 211 | 212 | async sub render_ptx { 213 | my $c = shift; 214 | 215 | $c->render_later; 216 | my $res = await WeBWorK::PreTeXt::render_ptx($c->req->params->to_hash); 217 | 218 | return $c->render(text => $res) unless ref($res) eq 'HASH'; 219 | 220 | $c->res->headers->content_type('text/xml; charset=utf-8'); 221 | return $c->render(template => 'RPCRenderFormats/ptx', %$res); 222 | } 223 | 224 | async sub sendAnswerJWT { 225 | my $c = shift; 226 | my $JWTanswerURL = shift; 227 | my $answerJWT = shift; 228 | 229 | # default response hash 230 | my $answerJWTresponse = { 231 | subject => 'webwork.result', 232 | message => 'initial message' 233 | }; 234 | my $header = { 235 | Origin => $ENV{SITE_HOST}, 236 | 'Content-Type' => 'text/plain', 237 | }; 238 | 239 | $c->log->info("sending answerJWT to $JWTanswerURL"); 240 | await $c->ua->max_redirects(5)->request_timeout(7)->post_p($JWTanswerURL, $header, $answerJWT)->then(sub { 241 | my $response = shift->result; 242 | 243 | $answerJWTresponse->{status} = int($response->code); 244 | # answerURL responses are expected to be JSON 245 | if ($response->json) { 246 | # munge data with default response object 247 | $answerJWTresponse = { %$answerJWTresponse, %{ $response->json } }; 248 | } else { 249 | # otherwise throw the whole body as the message 250 | $answerJWTresponse->{message} = $response->body; 251 | } 252 | })->catch(sub { 253 | my $err = shift; 254 | $c->log->error($err); 255 | 256 | $answerJWTresponse->{status} = 500; 257 | $answerJWTresponse->{message} = '[' . $c->logID . '] ' . $err; 258 | }); 259 | 260 | $answerJWTresponse = encode_json($answerJWTresponse); 261 | # this will become a string literal, so single-quote characters must be escaped 262 | $answerJWTresponse =~ s/'/\\'/g; 263 | $c->log->info("answerJWT response " . $answerJWTresponse); 264 | return $answerJWTresponse; 265 | } 266 | 267 | sub exception { 268 | my $c = shift; 269 | my $id = $c->logID; 270 | my $message = shift; 271 | $message = "[$id] " . (ref $message eq 'ARRAY' ? join "\n", @$message : $message); 272 | my $status = shift; 273 | $c->log->error("($status) EXCEPTION: $message"); 274 | return $c->respond_to( 275 | json => { 276 | json => { 277 | message => $message, 278 | status => $status, 279 | @_ 280 | }, 281 | status => $status 282 | }, 283 | html => { template => 'exception', message => $message, status => $status } 284 | ); 285 | } 286 | 287 | sub croak { 288 | my $c = shift; 289 | my $exception = shift; 290 | my $err_stack = $exception->message; 291 | my $depth = shift; 292 | 293 | my @err = split("\n", $err_stack); 294 | splice(@err, $depth, $#err) if ($depth <= scalar @err); 295 | $c->log->error(join "\n", @err); 296 | 297 | my $pretty_error = $err[0] =~ s/^(.*?) at .*$/$1/r; 298 | 299 | $c->exception($pretty_error, 500); 300 | return; 301 | } 302 | 303 | sub jweFromRequest { 304 | my $c = shift; 305 | my $inputs_ref = $c->parseRequest; 306 | return unless $inputs_ref; 307 | $inputs_ref->{aud} = $ENV{SITE_HOST}; 308 | my $req_jwt = encode_jwt( 309 | payload => $inputs_ref, 310 | key => $ENV{problemJWTsecret}, 311 | alg => 'PBES2-HS512+A256KW', 312 | enc => 'A256GCM', 313 | auto_iat => 1 314 | ); 315 | return $c->render(text => $req_jwt); 316 | } 317 | 318 | sub jwtFromRequest { 319 | my $c = shift; 320 | my $inputs_ref = $c->parseRequest; 321 | return unless $inputs_ref; 322 | $inputs_ref->{aud} = $ENV{SITE_HOST}; 323 | my $req_jwt = encode_jwt( 324 | payload => $inputs_ref, 325 | key => $ENV{problemJWTsecret}, 326 | alg => 'HS256', 327 | auto_iat => 1 328 | ); 329 | return $c->render(text => $req_jwt); 330 | } 331 | 332 | 1; 333 | -------------------------------------------------------------------------------- /lib/WeBWorK/FormatRenderedProblem.pm: -------------------------------------------------------------------------------- 1 | 2 | =head1 NAME 3 | 4 | FormatRenderedProblem.pm 5 | 6 | =cut 7 | 8 | package WeBWorK::FormatRenderedProblem; 9 | 10 | use strict; 11 | use warnings; 12 | 13 | use JSON; 14 | # use Digest::SHA qw(sha1_base64); 15 | use Mojo::Util qw(xml_escape); 16 | use Mojo::DOM; 17 | use Mojo::URL; 18 | 19 | use WeBWorK::Localize; 20 | use WeBWorK::Utils qw(getAssetURL); 21 | use WeBWorK::Utils::LanguageAndDirection; 22 | 23 | sub formatRenderedProblem { 24 | my $c = shift; 25 | my $rh_result = shift; 26 | my $inputs_ref = $rh_result->{inputs_ref}; 27 | 28 | my $renderErrorOccurred = 0; 29 | 30 | my $problemText = $rh_result->{text} // ''; 31 | $problemText .= $rh_result->{flags}{comment} if ($rh_result->{flags}{comment} && $inputs_ref->{showComments}); 32 | 33 | if ($rh_result->{flags}{error_flag}) { 34 | $rh_result->{problem_result}{score} = 0; # force score to 0 for such errors. 35 | $renderErrorOccurred = 1; 36 | } 37 | 38 | # TODO: add configuration to disable these overrides 39 | my $SITE_URL = $inputs_ref->{baseURL} ? Mojo::URL->new($inputs_ref->{baseURL}) : $main::basehref; 40 | my $FORM_ACTION_URL = $inputs_ref->{formURL} ? Mojo::URL->new($inputs_ref->{formURL}) : $main::formURL; 41 | 42 | my $displayMode = $inputs_ref->{displayMode} // 'MathJax'; 43 | 44 | # HTML document language setting 45 | my $formLanguage = $inputs_ref->{language} // 'en'; 46 | 47 | # Third party CSS 48 | my @third_party_css = map { getAssetURL($formLanguage, $_->[0]) } ( 49 | [ 'css/bootstrap.css', ], 50 | [ 'node_modules/jquery-ui-dist/jquery-ui.min.css', ], 51 | ['node_modules/@fortawesome/fontawesome-free/css/all.min.css'], 52 | ); 53 | 54 | # Add CSS files requested by problems via ADD_CSS_FILE() in the PG file 55 | # or via a setting of $ce->{pg}{specialPGEnvironmentVars}{extra_css_files} 56 | # which can be set in course.conf (the value should be an anonomous array). 57 | my @cssFiles; 58 | # if (ref($ce->{pg}{specialPGEnvironmentVars}{extra_css_files}) eq 'ARRAY') { 59 | # push(@cssFiles, { file => $_, external => 0 }) for @{ $ce->{pg}{specialPGEnvironmentVars}{extra_css_files} }; 60 | # } 61 | if (ref($rh_result->{flags}{extra_css_files}) eq 'ARRAY') { 62 | push @cssFiles, @{ $rh_result->{flags}{extra_css_files} }; 63 | } 64 | my %cssFilesAdded; # Used to avoid duplicates 65 | my @extra_css_files; 66 | for (@cssFiles) { 67 | next if $cssFilesAdded{ $_->{file} }; 68 | $cssFilesAdded{ $_->{file} } = 1; 69 | if ($_->{external}) { 70 | push(@extra_css_files, $_); 71 | } else { 72 | push(@extra_css_files, { file => getAssetURL($formLanguage, $_->{file}), external => 0 }); 73 | } 74 | } 75 | 76 | # Third party JavaScript 77 | # The second element is a hash containing the necessary attributes for the script tag. 78 | my @third_party_js = map { [ getAssetURL($formLanguage, $_->[0]), $_->[1] ] } ( 79 | [ 'node_modules/jquery/dist/jquery.min.js', {} ], 80 | [ 'node_modules/jquery-ui-dist/jquery-ui.min.js', {} ], 81 | [ 'node_modules/iframe-resizer/js/iframeResizer.contentWindow.min.js', {} ], 82 | [ "js/apps/MathJaxConfig/mathjax-config.js", { defer => undef } ], 83 | [ 'node_modules/mathjax/es5/tex-svg.js', { defer => undef, id => 'MathJax-script' } ], 84 | [ 'node_modules/bootstrap/dist/js/bootstrap.bundle.min.js', { defer => undef } ], 85 | [ "js/apps/Problem/problem.js", { defer => undef } ], 86 | [ "js/apps/Problem/submithelper.js", { defer => undef } ], 87 | [ "js/apps/CSSMessage/css-message.js", { defer => undef } ], 88 | ); 89 | 90 | # Get the requested format. (outputFormat or outputformat) 91 | # override to static mode if showCorrectAnswers has been set 92 | my $formatName = $inputs_ref->{showCorrectAnswers} 93 | && !$inputs_ref->{isInstructor} ? 'static' : ($inputs_ref->{outputFormat} || 'default'); 94 | 95 | # Add JS files requested by problems via ADD_JS_FILE() in the PG file. 96 | my @extra_js_files; 97 | if (ref($rh_result->{flags}{extra_js_files}) eq 'ARRAY') { 98 | my %jsFiles; 99 | for (@{ $rh_result->{flags}{extra_js_files} }) { 100 | next if $jsFiles{ $_->{file} }; 101 | $jsFiles{ $_->{file} } = 1; 102 | my %attributes = ref($_->{attributes}) eq 'HASH' ? %{ $_->{attributes} } : (); 103 | if ($_->{external}) { 104 | push(@extra_js_files, $_); 105 | } else { 106 | push( 107 | @extra_js_files, 108 | { 109 | file => getAssetURL($formLanguage, $_->{file}), 110 | external => 0, 111 | attributes => $_->{attributes} 112 | } 113 | ); 114 | } 115 | } 116 | } 117 | 118 | # Set up the problem language and direction 119 | # PG files can request their language and text direction be set. If we do not have access to a default course 120 | # language, fall back to the $formLanguage instead. 121 | # TODO: support for right-to-left languages 122 | my %PROBLEM_LANG_AND_DIR = get_problem_lang_and_dir($rh_result->{flags}, 'auto:en:ltr', $formLanguage); 123 | my $PROBLEM_LANG_AND_DIR = join(' ', map {qq{$_="$PROBLEM_LANG_AND_DIR{$_}"}} keys %PROBLEM_LANG_AND_DIR); 124 | 125 | # is there a reason this doesn't use the same button IDs? 126 | my $previewMode = defined($inputs_ref->{previewAnswers}) || 0; 127 | my $submitMode = defined($inputs_ref->{submitAnswers}) || $inputs_ref->{answersSubmitted} || 0; 128 | my $showCorrectMode = defined($inputs_ref->{showCorrectAnswers}) || 0; 129 | # A problemUUID should be added to the request as a parameter. It is used by PG to create a proper UUID for use in 130 | # aliases for resources. It should be unique for a course, user, set, problem, and version. 131 | my $problemUUID = $inputs_ref->{problemUUID} // ''; 132 | my $problemResult = $rh_result->{problem_result} // {}; 133 | my $showSummary = $inputs_ref->{showSummary} // 1; 134 | my $showScoreSummary = $inputs_ref->{showScoreSummary} // 0; 135 | # my $showAnswerNumbers = $inputs_ref->{showAnswerNumbers} // 0; # default no 136 | # allow the request to hide the results table or messages 137 | my $showTable = $inputs_ref->{hideAttemptsTable} ? 0 : 1; 138 | my $showMessages = $inputs_ref->{hideMessages} ? 0 : 1; 139 | # allow the request to override the display of partial correct answers 140 | my $showPartialCorrectAnswers = $inputs_ref->{showPartialCorrectAnswers} 141 | // $rh_result->{flags}{showPartialCorrectAnswers}; 142 | 143 | # Do not produce a result summary when we had a rendering error. 144 | my $resultSummary = ''; 145 | if (!$renderErrorOccurred 146 | && $showSummary 147 | && !$previewMode 148 | && ($submitMode || $showCorrectMode) 149 | && $problemResult->{summary}) 150 | { 151 | $resultSummary = $c->c( 152 | $c->tag( 153 | 'h2', 154 | class => 'fs-3 mb-2', 155 | 'Results for this submission' 156 | ) 157 | . $c->tag('div', role => 'alert', $c->b($problemResult->{summary})) 158 | )->join(''); 159 | } 160 | 161 | # Answer hash in XML format used by the PTX format. 162 | my $answerhashXML = ''; 163 | if ($formatName eq 'ptx') { 164 | my $dom = Mojo::DOM->new->xml(1); 165 | for my $answer (sort keys %{ $rh_result->{answers} }) { 166 | $dom->append_content($dom->new_tag( 167 | $answer, 168 | map { $_ => ($rh_result->{answers}{$answer}{$_} // '') } keys %{ $rh_result->{answers}{$answer} } 169 | )); 170 | } 171 | $dom->wrap_content(''); 172 | $answerhashXML = $dom->to_string; 173 | } 174 | 175 | # Make sure this is defined and is an array reference as saveGradeToLTI might add to it. 176 | $rh_result->{debug_messages} = [] unless defined $rh_result && ref $rh_result->{debug_messages} eq 'ARRAY'; 177 | 178 | # Execute and return the interpolated problem template 179 | 180 | # Raw format 181 | # This format returns javascript object notation corresponding to the perl hash 182 | # with everything that a client-side application could use to work with the problem. 183 | # There is no wrapping HTML "_format" template. 184 | if ($formatName eq 'raw') { 185 | my $output = {}; 186 | 187 | # Everything that ships out with other formats can be constructed from these 188 | $output->{rh_result} = $rh_result; 189 | $output->{inputs_ref} = $inputs_ref; 190 | 191 | # The following could be constructed from the above, but this is a convenience 192 | # $output->{answerTemplate} = $answerTemplate->to_string if ($answerTemplate); 193 | $output->{resultSummary} = $resultSummary->to_string if $resultSummary; 194 | $output->{lang} = $PROBLEM_LANG_AND_DIR{lang}; 195 | $output->{dir} = $PROBLEM_LANG_AND_DIR{dir}; 196 | $output->{extra_css_files} = \@extra_css_files; 197 | $output->{extra_js_files} = \@extra_js_files; 198 | 199 | # Include third party css and javascript files. Only jquery, jquery-ui, mathjax, and bootstrap are needed for 200 | # PG. See the comments before the subroutine definitions for load_css and load_js in pg/macros/PG.pl. 201 | # The other files included are only needed to make themes work in the webwork2 formats. 202 | $output->{third_party_css} = \@third_party_css; 203 | $output->{third_party_js} = \@third_party_js; 204 | 205 | # Convert to JSON and render. 206 | return $c->render(data => JSON->new->utf8(1)->encode($output)); 207 | } 208 | 209 | # Setup and render the appropriate template in the templates/RPCRenderFormats folder depending on the outputformat. 210 | # "ptx" has a special template. "json" uses the default json template. All others use the default html template. 211 | my %template_params = ( 212 | template => $formatName eq 'ptx' ? 'RPCRenderFormats/ptx' : 'RPCRenderFormats/default', 213 | $formatName eq 'json' ? (format => 'json') : (), 214 | formatName => $formatName, 215 | lh => WeBWorK::Localize::getLangHandle($inputs_ref->{language} // 'en'), 216 | rh_result => $rh_result, 217 | SITE_URL => $SITE_URL, 218 | FORM_ACTION_URL => $FORM_ACTION_URL, 219 | COURSE_LANG_AND_DIR => get_lang_and_dir($formLanguage), 220 | PROBLEM_LANG_AND_DIR => $PROBLEM_LANG_AND_DIR, 221 | third_party_css => \@third_party_css, 222 | extra_css_files => \@extra_css_files, 223 | third_party_js => \@third_party_js, 224 | extra_js_files => \@extra_js_files, 225 | problemText => $problemText, 226 | extra_header_text => $inputs_ref->{extra_header_text} // '', 227 | resultSummary => $resultSummary, 228 | showSummary => $showSummary, 229 | showScoreSummary => $submitMode && !$renderErrorOccurred && !$previewMode && $problemResult, 230 | answerhashXML => $answerhashXML, 231 | showPreviewButton => $inputs_ref->{hidePreviewButton} ? '0' : '', 232 | showCheckAnswersButton => $inputs_ref->{hideCheckAnswersButton} ? '0' : '', 233 | showCorrectAnswersButton => $inputs_ref->{showCorrectAnswersButton} 234 | // ($inputs_ref->{isInstructor} ? '' : '0'), 235 | showFooter => $inputs_ref->{showFooter} // '0', 236 | pretty_print => \&pretty_print, 237 | ); 238 | 239 | return $c->render(%template_params) if $formatName eq 'json'; 240 | $rh_result->{renderedHTML} = $c->render_to_string(%template_params)->to_string; 241 | return $c->respond_to( 242 | html => { text => $rh_result->{renderedHTML} }, 243 | json => { 244 | json => jsonResponse( 245 | $rh_result, $inputs_ref, @extra_css_files, @third_party_css, @extra_js_files, @third_party_js 246 | ) 247 | }, 248 | ); 249 | } 250 | 251 | sub jsonResponse { 252 | my ($rh_result, $inputs_ref, @extra_files) = @_; 253 | return { 254 | ( 255 | $inputs_ref->{isInstructor} 256 | ? ( 257 | answers => $rh_result->{answers}, 258 | inputs => $inputs_ref, 259 | pgcore => { 260 | persist => $rh_result->{PERSISTENCE_HASH}, 261 | persist_up => $rh_result->{PERSISTENCE_HASH_UPDATED}, 262 | pgah => $rh_result->{PG_ANSWERS_HASH} 263 | } 264 | ) 265 | : () 266 | ), 267 | ( 268 | $inputs_ref->{includeTags} 269 | ? (tags => $rh_result->{tags}, raw_metadata_text => $rh_result->{raw_metadata_text}) 270 | : () 271 | ), 272 | renderedHTML => $rh_result->{renderedHTML}, 273 | debug => { 274 | perl_warn => $rh_result->{WARNINGS}, 275 | pg_warn => $rh_result->{warning_messages}, 276 | debug => $rh_result->{debug_messages}, 277 | internal => $rh_result->{internal_debug_messages} 278 | }, 279 | problem_result => $rh_result->{problem_result}, 280 | problem_state => $rh_result->{problem_state}, 281 | flags => $rh_result->{flags}, 282 | resources => { 283 | regex => $rh_result->{pgResources}, 284 | alias => $rh_result->{resources}, 285 | assets => 286 | [ map { ref $_ eq 'HASH' ? "$_->{file}" : ref $_ eq 'ARRAY' ? "$_->[0]" : "$_" } @extra_files ], 287 | }, 288 | JWT => { 289 | problem => $inputs_ref->{problemJWT}, 290 | session => $rh_result->{sessionJWT}, 291 | answer => $rh_result->{answerJWT} 292 | }, 293 | }; 294 | } 295 | 296 | # Nice output for debugging 297 | sub pretty_print { 298 | my ($r_input, $level) = @_; 299 | return 'undef' unless defined $r_input; 300 | 301 | $level //= 4; 302 | $level--; 303 | return 'too deep' unless $level > 0; # Only print three levels of hashes (safety feature) 304 | 305 | my $ref = ref($r_input); 306 | 307 | if (!$ref) { 308 | return xml_escape($r_input); 309 | } elsif (eval { %$r_input && 1 }) { 310 | # `eval { %$r_input && 1 }` will pick up all objects that can be accessed like a hash and so works better than 311 | # `ref $r_input`. Do not use `"$r_input" =~ /hash/i` because that will pick up strings containing the word 312 | # hash, and that will cause an error below. 313 | my $out = 314 | '
' 315 | . ($ref eq 'HASH' 316 | ? '' 317 | : '
' 319 | . "$ref
") 320 | . '
'; 321 | for my $key (sort keys %$r_input) { 322 | # Safety feature - we do not want to display the contents of %seed_ce which 323 | # contains the database password and lots of other things, and explicitly hide 324 | # certain internals of the CourseEnvironment in case one slips in. 325 | next 326 | if (($key =~ /database/) 327 | || ($key =~ /dbLayout/) 328 | || ($key eq "ConfigValues") 329 | || ($key eq "ENV") 330 | || ($key eq "externalPrograms") 331 | || ($key eq "permissionLevels") 332 | || ($key eq "seed_ce")); 333 | $out .= 334 | '
' 335 | . xml_escape($key) 336 | . '
' 337 | . qq{
=>
} 338 | . qq{
} 339 | . pretty_print($r_input->{$key}, $level) 340 | . '
'; 341 | } 342 | $out .= '
'; 343 | return $out; 344 | } elsif ($ref eq 'ARRAY') { 345 | return '[ ' . join(', ', map { pretty_print($_, $level) } @$r_input) . ' ]'; 346 | } elsif ($ref eq 'CODE') { 347 | return 'CODE'; 348 | } else { 349 | return xml_escape($r_input); 350 | } 351 | } 352 | 353 | 1; 354 | -------------------------------------------------------------------------------- /lib/WeBWorK/Utils/Tags.pm: -------------------------------------------------------------------------------- 1 | ########################### 2 | # Utils::Tags 3 | # 4 | # Provides basic handling of OPL tags 5 | ########################### 6 | 7 | package WeBWorK::Utils::Tags; 8 | 9 | use base qw(Exporter); 10 | use strict; 11 | use warnings; 12 | use Carp; 13 | use IO::File; 14 | 15 | our @EXPORT = (); 16 | our @EXPORT_OK = qw(); 17 | 18 | use constant BASIC => qw( DBsubject DBchapter DBsection Date Institution Author MLT MLTleader Level Language Static MO Status ); 19 | use constant NUMBERED => qw( TitleText AuthorText EditionText Section Problem ); 20 | 21 | # KEYWORDS and RESOURCES are treated specially since each takes a list of values 22 | 23 | my $basics = join('|', BASIC); 24 | my $numbered = join('|', NUMBERED); 25 | my $re = qr/#\s*\b($basics)\s*\(\s*['"]?(.*?)['"]?\s*\)\s*$/; 26 | 27 | sub istagline { 28 | my $line = shift; 29 | return 1 if($line =~ /$re/); 30 | return 1 if($line =~ /#\s*\bKEYWORDS?\s*\(\s*'?(.*?)'?\s*\)/); 31 | return 1 if($line =~ /#\s*\bRESOURCES?\s*\(\s*'?(.*?)'?\s*\)/); 32 | return 1 if($line =~ /#\s*\b($numbered)\d+\s*\(\s*'?(.*?)'?\s*\)/); 33 | return 0; 34 | } 35 | 36 | sub isStartDescription { 37 | my $line = shift; 38 | return ($line =~ /DESCRIPTION/) ? 1 : 0; 39 | } 40 | 41 | sub isEndDescription { 42 | my $line = shift; 43 | return ($line =~ /ENDDESCRIPTION/) ? 1 : 0; 44 | } 45 | 46 | # sub kwtidy { 47 | # my $s = shift; 48 | # $s =~ s/\W//g; 49 | # $s =~ s/_//g; 50 | # $s = lc($s); 51 | # return($s); 52 | # } 53 | 54 | # sub keywordcleaner { 55 | # my $string = shift; 56 | # my @spl1 = split /,/, $string; 57 | # foreach my $keyword (@spl1) { 58 | # # strip quotes and trim, then lowercase 59 | # # $keyword =~ s/^\s*['"]\s*(.*?)\s*['"]\s*$/$1/; 60 | # # $keyword = lc $keyword; 61 | # $keyword = kwtidy($keyword); 62 | # } 63 | # # my @spl2 = map(kwtidy($_), @spl1); 64 | # return(@spl1); 65 | # } 66 | my $quote = qr/['"\x{2018}\x{2019}\x{91}\x{92}]/; 67 | my $space = qr/[\s\x{85}]/; 68 | my $kwtidy_qr = qr/^$space*$quote*$space*(.*?)$space*$quote*$space*$/; 69 | my $kwcleaner_qr = qr/$quote$space*$quote/; 70 | 71 | sub trim { 72 | my $s = shift; 73 | $s =~ s/^$space*$quote*$space*//; 74 | $s =~ s/$space*$quote*$space*$//; 75 | return $s; 76 | } 77 | 78 | sub kwtidy { 79 | my $s = shift; 80 | $s =~ s/$kwtidy_qr/$1/; 81 | $s =~ s/[_\s]/-/g; 82 | $s = lc($s); 83 | return ( $s =~ /\S/ ) ? $s : (); 84 | } 85 | 86 | sub keywordcleaner { 87 | my $string = shift; 88 | my @spl1 = split /[;,.]/, $string; 89 | @spl1 = map( { split( $kwcleaner_qr, $_ ) } @spl1 ); 90 | return map( kwtidy($_), @spl1 ); 91 | } 92 | 93 | sub mergekeywords { 94 | my $self=shift; 95 | my $kws=shift; 96 | if(not defined($self->{keywords})) { 97 | $self->{keywords} = $kws; 98 | return; 99 | } 100 | if(not defined($kws)) { 101 | return; 102 | } 103 | my @kw = @{$self->{keywords}}; 104 | for my $j (@{$kws}) { 105 | my $old = 0; 106 | for my $k (@kw) { 107 | if(lc($k) eq lc($j)) { 108 | $old = 1; 109 | last; 110 | } 111 | } 112 | push @kw, $j unless ($old); 113 | } 114 | $self->{keywords} = \@kw; 115 | } 116 | 117 | # Note on texts, we store them in an array, but the index is one less than on 118 | # the corresponding tag. 119 | sub isnewtext { 120 | my $self = shift; 121 | my $ti = shift; 122 | for my $j (@{$self->{textinfo}}) { 123 | my $ok = 1; 124 | for my $k ('TitleText', 'EditionText', 'AuthorText') { 125 | if($ti->{$k} ne $j->{$k}) { 126 | $ok = 0; 127 | last; 128 | } 129 | } 130 | return 0 if($ok); 131 | } 132 | return 1; 133 | } 134 | 135 | sub mergetexts { 136 | my $self=shift; 137 | my $newti=shift; 138 | for my $ti (@$newti) { 139 | if($self->isnewtext($ti)) { 140 | my @tia = @{$self->{textinfo}}; 141 | push @tia, $ti; 142 | $self->{textinfo} = \@tia; 143 | } 144 | } 145 | } 146 | 147 | # Set a tag with a value 148 | sub settag { 149 | my $self = shift; 150 | my $tagname = shift; 151 | my $newval = shift; 152 | my $force = shift; 153 | 154 | if(defined($newval) and ((defined($force) and $force) or $newval) and ((not defined($self->{$tagname})) or ($newval ne $self->{$tagname}))) { 155 | $self->{modified}=1; 156 | $self->{$tagname} = $newval; 157 | } 158 | } 159 | 160 | # Similar, but add a resource to the list 161 | sub addresource { 162 | my $self = shift; 163 | my $resc = shift; 164 | 165 | if(not defined($self->{resources})) { 166 | $self->{resources} = [$resc]; 167 | } else { 168 | unless(grep(/^$resc$/, @{$self->{resources}} )) { 169 | push @{$self->{resources}}, $resc; 170 | } 171 | } 172 | } 173 | 174 | sub printtextinfo { 175 | my $textref = shift; 176 | print "{"; 177 | for my $k (keys %{$textref}){ 178 | print "$k -> ".$textref->{$k}.", "; 179 | } 180 | print "}\n"; 181 | } 182 | 183 | sub printalltextinfo { 184 | my $self = shift; 185 | for my $j (@{$self->{textinfo}}) { 186 | printtextinfo $j; 187 | } 188 | } 189 | 190 | sub maybenewtext { 191 | my $textno = shift; 192 | my $textinfo = shift ; 193 | return $textinfo if defined($textinfo->[$textno-1]); 194 | # So, not defined yet 195 | $textinfo->[$textno-1] = { TitleText => '', AuthorText =>'', EditionText =>'', 196 | section => '', chapter =>'', problems => [] }; 197 | return $textinfo; 198 | } 199 | 200 | sub gettextnos { 201 | my $textinfo = shift; 202 | return grep { defined $textinfo->[$_] } (0..(scalar(@{$textinfo})-1)); 203 | } 204 | 205 | sub tidytextinfo { 206 | my $self = shift; 207 | my @textnos = gettextnos($self->{textinfo}); 208 | my $ntxts = scalar(@textnos); 209 | if($ntxts and ($ntxts-1) != $textnos[-1]) { 210 | $self->{modified} = 1; 211 | my @tmptexts = grep{ defined $_ } @{$self->{textinfo}}; 212 | $self->{textinfo} = \@tmptexts; 213 | } 214 | } 215 | 216 | 217 | # name is a path 218 | 219 | sub new { 220 | my $class = shift; 221 | my $name = shift; 222 | my $source = shift; 223 | my $self = {}; 224 | 225 | $self->{isplaceholder} = 0; 226 | $self->{modified} = 0; 227 | my $lasttag = 1; 228 | 229 | my ($text, $edition, $textauthor, $textsection, $textproblem); 230 | my $textno; 231 | my $textinfo=[]; 232 | my @lines = (); 233 | 234 | if ($source) { 235 | @lines = split "\n", $source; 236 | } else { 237 | if ( $name !~ /pg$/ && $name !~ /\.pg\.[-a-zA-Z0-9_.@]*\.tmp$/ ) { 238 | warn "Not a pg file"; #print caused trouble with XMLRPC 239 | $self->{file} = undef; 240 | bless( $self, $class ); 241 | return $self; 242 | } 243 | open( IN, '<:encoding(UTF-8)', "$name" ) or die "can not open $name: $!"; 244 | @lines = ; 245 | close IN; 246 | } 247 | 248 | my $lineno = 0; 249 | $self->{file} = $name; 250 | 251 | # Initialize some values 252 | for my $tagname ( BASIC ) { 253 | $self->{$tagname} = ''; 254 | } 255 | $self->{keywords} = []; 256 | $self->{resources} = []; 257 | $self->{description} = []; 258 | my $inDescription = 0; 259 | $self->{Language} = 'en'; # Default to English 260 | 261 | 262 | foreach (@lines) { 263 | $lineno++; 264 | eval { 265 | SWITCH: { 266 | if (/^#+\s*\bDESCRIPTION/i) { 267 | $inDescription = 1; 268 | last SWITCH; 269 | } 270 | if ($inDescription) { 271 | # we cannot assume that all problems have ENDDESCRIPTION 272 | # check if we have a valid tag-line and continue processing if so 273 | if (istagline($_)) { 274 | $inDescription = 0; 275 | } else { 276 | if (/^#+\s*\bENDDESCRIPTION/i) { 277 | $inDescription = 0; 278 | } 279 | elsif (/^#+\s*(.*)/) { 280 | push @{ $self->{description} }, $1; 281 | } 282 | last SWITCH; 283 | } 284 | } 285 | if (/#\s*\bKEYWORDS\((.*)\)/i) { 286 | my @keyword = keywordcleaner($1); 287 | @keyword = grep { not /^\s*'?\s*'?\s*$/ } @keyword; 288 | $self->{keywords} = [@keyword]; 289 | $lasttag = $lineno; 290 | last SWITCH; 291 | } 292 | if (/#\s*\bRESOURCES\((.*)\)/i) { 293 | my @resc = split ',', $1; 294 | s/["'\s]*$//g for (@resc); 295 | s/^["'\s]*//g for (@resc); 296 | @resc = grep { not /^\s*'?\s*'?\s*$/ } @resc; 297 | $self->{resources} = [@resc]; 298 | $lasttag = $lineno; 299 | last SWITCH; 300 | } 301 | if (/$re/) { # Checks all other un-numbered tags 302 | my $tmp1 = $1; 303 | my $tmp = trim($2); 304 | 305 | #$tmp =~ s/'/\'/g; 306 | # $tmp =~ s/\s+$//; 307 | # $tmp =~ s/^\s+//; 308 | $self->{$tmp1} = $tmp; 309 | $lasttag = $lineno; 310 | last SWITCH; 311 | } 312 | 313 | if (/#\s*\bTitleText(\d+)\(\s*'?(.*?)'?\s*\)/) { 314 | $textno = $1; 315 | $text = $2; 316 | $text =~ s/'/\'/g; 317 | if ( $text =~ /\S/ ) { 318 | $textinfo = maybenewtext( $textno, $textinfo ); 319 | $textinfo->[ $textno - 1 ]->{TitleText} = $text; 320 | } 321 | $lasttag = $lineno; 322 | last SWITCH; 323 | } 324 | if (/#\s*\bEditionText(\d+)\(\s*'?(.*?)'?\s*\)/) { 325 | $textno = $1; 326 | $edition = $2; 327 | $edition =~ s/'/\'/g; 328 | if ( $edition =~ /\S/ ) { 329 | $textinfo = maybenewtext( $textno, $textinfo ); 330 | $textinfo->[ $textno - 1 ]->{EditionText} = $edition; 331 | } 332 | $lasttag = $lineno; 333 | last SWITCH; 334 | } 335 | if (/#\s*\bAuthorText(\d+)\(\s*'?(.*?)'?\s*\)/) { 336 | $textno = $1; 337 | $textauthor = $2; 338 | $textauthor =~ s/'/\'/g; 339 | if ( $textauthor =~ /\S/ ) { 340 | $textinfo = maybenewtext( $textno, $textinfo ); 341 | $textinfo->[ $textno - 1 ]->{AuthorText} = $textauthor; 342 | } 343 | $lasttag = $lineno; 344 | last SWITCH; 345 | } 346 | if (/#\s*\bSection(\d+)\(\s*'?(.*?)'?\s*\)/) { 347 | $textno = $1; 348 | $textsection = $2; 349 | $textsection =~ s/'/\'/g; 350 | $textsection =~ s/[^\d\.]//g; 351 | 352 | #print "|$textsection|\n"; 353 | if ( $textsection =~ /\S/ ) { 354 | $textinfo = maybenewtext( $textno, $textinfo ); 355 | if ( $textsection =~ /(\d*?)\.(\d*)/ ) { 356 | $textinfo->[ $textno - 1 ]->{chapter} = $1; 357 | $textinfo->[ $textno - 1 ]->{section} = $2; 358 | } 359 | else { 360 | $textinfo->[ $textno - 1 ]->{chapter} = $textsection; 361 | $textinfo->[ $textno - 1 ]->{section} = -1; 362 | } 363 | } 364 | $lasttag = $lineno; 365 | last SWITCH; 366 | } 367 | if (/#\s*\bProblem(\d+)\(\s*(.*?)\s*\)/) { 368 | $textno = $1; 369 | $textproblem = $2; 370 | $textproblem =~ s/\D/ /g; 371 | my @textproblems = (-1); 372 | @textproblems = split /\s+/, $textproblem; 373 | @textproblems = grep { $_ =~ /\S/ } @textproblems; 374 | if ( scalar(@textproblems) or defined( $textinfo->[$textno] ) ) 375 | { 376 | @textproblems = (-1) unless ( scalar(@textproblems) ); 377 | $textinfo = maybenewtext( $textno, $textinfo ); 378 | $textinfo->[ $textno - 1 ]->{problems} = \@textproblems; 379 | } 380 | $lasttag = $lineno; 381 | last SWITCH; 382 | } 383 | } # end of SWITCH 384 | }; # end of eval error trap 385 | warn "error reading problem $name $!, $@ " if $@; 386 | } #end of while 387 | $self->{textinfo} = $textinfo; 388 | 389 | if (defined($self->{DBchapter}) and $self->{DBchapter} eq 'ZZZ-Inserted Text') { 390 | $self->{isplaceholder} = 1; 391 | } 392 | 393 | 394 | $self->{lasttagline}=$lasttag; 395 | bless($self, $class); 396 | $self->tidytextinfo(); 397 | # $self->printalltextinfo(); 398 | return $self; 399 | } 400 | 401 | sub isplaceholder { 402 | my $self = shift; 403 | return $self->{isplaceholder}; 404 | } 405 | 406 | sub istagged { 407 | my $self = shift; 408 | #return 1 if (defined($self->{DBchapter}) and $self->{DBchapter} and (not $self->{isplaceholder})); 409 | return 1 if (defined($self->{DBsubject}) and $self->{DBsubject} and (not $self->{isplaceholder})); 410 | return 0; 411 | } 412 | 413 | # Try to copy in the contents of another Tag object. 414 | # Return 1 if ok, 0 if not compatible 415 | sub copyin { 416 | my $self = shift; 417 | my $ob = shift; 418 | # for my $j (qw( DBsubject DBchapter DBsection )) { 419 | # if($self->{$j} =~ /\S/ and $ob->{$j} =~ /\S/ and $self->{$j} ne $ob->{$j}) { 420 | # # print "Incompatible $j: ".$self->{$j}." vs ".$ob->{$j} ."\n"; 421 | # return 0; 422 | # } 423 | # } 424 | # Just copy in all basic tags 425 | for my $j (qw( DBsubject DBchapter DBsection MLT MLTleader Level )) { 426 | $self->settag($j, $ob->{$j}) if(defined($ob->{$j})); 427 | } 428 | # Now copy in keywords 429 | $self->mergekeywords($ob->{keywords}); 430 | # Finally, textbooks 431 | $self->mergetexts($ob->{textinfo}); 432 | return 1; 433 | } 434 | 435 | sub dumptags { 436 | my $self = shift; 437 | my $fh = shift; 438 | if ( $self->{description} ) { 439 | if (ref( $self->{description} ) !~ /ARRAY/) { 440 | warn "TAGS.PM: dumping description, but it wasn't an array..."; 441 | $self->{description} = [$self->{description}]; 442 | } 443 | my @descriptionArray = @{$self->{description}}; 444 | unshift @descriptionArray, "## DESCRIPTION"; 445 | push @descriptionArray, "ENDDESCRIPTION"; 446 | print $fh join("\n## ", @descriptionArray)."\n"; 447 | } 448 | 449 | if ( $self->{keywords} ) { 450 | if (ref( $self->{keywords} ) !~ /ARRAY/) { 451 | warn "TAGS.PM: dumping keywords, but it wasn't an array...\n"; 452 | $self->{keywords} = [ keywordcleaner( $self->{keywords} ) ]; 453 | } else { 454 | @{ $self->{keywords} } = map { kwtidy($_) } @{ $self->{keywords} }; 455 | } 456 | } 457 | 458 | for my $tagname ( BASIC ) { 459 | print $fh "## $tagname(".$self->{$tagname}.")\n" if($self->{$tagname}); 460 | } 461 | my @textinfo = @{$self->{textinfo}}; 462 | my $textno = 0; 463 | for my $ti (@textinfo) { 464 | $textno++; 465 | for my $nw ( NUMBERED ) { 466 | if($nw eq 'Problem') { 467 | print $fh "## $nw$textno('".join(' ', @{$ti->{problems}})."')\n"; 468 | next; 469 | } 470 | if($nw eq 'Section') { 471 | if($ti->{section} eq '-1') { 472 | print $fh "## Section$textno('".$ti->{chapter}."')\n"; 473 | } else { 474 | print $fh "## Section$textno('".$ti->{chapter}.".".$ti->{section}."')\n"; 475 | } 476 | next; 477 | } 478 | print $fh "## $nw$textno('".$ti->{$nw}."')\n"; 479 | } 480 | } 481 | print $fh "## KEYWORDS(".join(',', @{$self->{keywords}}).")\n" if(scalar(@{$self->{keywords}})); 482 | my @resc; 483 | if(scalar(@{$self->{resources}})) { 484 | @resc = @{$self->{resources}}; 485 | s/^/'/g for (@resc); 486 | s/$/'/g for (@resc); 487 | print $fh "## RESOURCES(".join(',', @resc).")\n"; 488 | } 489 | } 490 | 491 | # Write the file 492 | sub write { 493 | my $self=shift; 494 | # First read it into an array 495 | open(IN,$self->{file}) or die "can not open $self->{file}: $!"; 496 | my @lines = ; 497 | close(IN); 498 | my $fh = IO::File->new(">".$self->{file}) or die "can not open $self->{file}: $!"; 499 | my ($line, $lineno, $inDescription)=('', 0, 0); 500 | while($line = shift @lines) { 501 | $lineno++; 502 | $self->dumptags($fh) if($lineno == $self->{lasttagline}); 503 | $inDescription = isStartDescription($line) unless $inDescription; 504 | if ($inDescription) { 505 | # do not assume every DESCRIPTION has an ENDDESCRIPTION 506 | if (isEndDescription($line) || istagline($line)) { 507 | $inDescription = 0; 508 | } 509 | next; 510 | } 511 | next if istagline($line); 512 | print $fh $line unless $lineno < $self->{lasttagline}; 513 | } 514 | 515 | $fh->close(); 516 | } 517 | 518 | 1; 519 | --------------------------------------------------------------------------------