diff --git a/.gitignore b/.gitignore index 050076ad..bbe0c190 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ **/*.class **/*.zip **/*.jar -development.conf +**/*.bloop +conf/development.conf conf/production.conf +conf/staging.conf production.env antbuild project/project @@ -17,6 +19,7 @@ target .cache-main .bsp .metals +.vscode application-log* gcloud deploy @@ -24,4 +27,5 @@ checkall testdiff /bin checker/docker/build +comrun/build todo.txt diff --git a/README.md b/README.md index a3fd1811..24e5ca4a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ -CodeCheck^®^ +CodeCheck® ============ +* This project has been superseded by [codecheck3](https://github.com/cayhorstmann/codecheck3) * [Description](https://codecheck.io) * [Build Instructions](https://github.com/cayhorstmann/codecheck2/blob/main/build-instructions.md) diff --git a/app/com/horstmann/codecheck/Annotations.java b/app/com/horstmann/codecheck/Annotations.java index e4887a89..a68f29d8 100644 --- a/app/com/horstmann/codecheck/Annotations.java +++ b/app/com/horstmann/codecheck/Annotations.java @@ -5,6 +5,8 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; @@ -12,10 +14,36 @@ import java.util.regex.Pattern; public class Annotations { - public static final Set VALID_ANNOTATIONS = Set.of( - "HIDE", "SHOW", "EDIT", "SOLUTION", "CALL", "SUB", "ID", "SAMPLE", "ARGS", - "IN", "OUT", "TIMEOUT", "TOLERANCE", "IGNORECASE", "IGNORESPACE", "MAXOUTPUTLEN", - "REQUIRED", "FORBIDDEN", "SCORING", "INTERLEAVE", "TILE", "FIXED", "OR", "PSEUDO"); + public static final Set VALID_ANNOTATIONS = new LinkedHashSet<>(List.of( + "ARGS", + "CALL HIDDEN", + "CALL", + "HIDDEN", + "HIDE", + "EDIT", + "FIXED", + "FORBIDDEN", + "ID", + "SAMPLE", + "SUB", + "IGNORECASE", + "IGNORESPACE", + "INTERLEAVE", + "IN HIDDEN", + "IN", + "MAXOUTPUTLEN", + "OR", + "OUT", + "PSEUDO", + "REQUIRED", + "SCORING", + "SHOW", + "SOLUTION", + "TILE", + "TIMEOUT", + "TOLERANCE" + )); + // TODO SAMPLE, SCORING legacy public static final Set NON_BLANK_BEFORE_OK = Set.of("SUB", "PSEUDO"); public static class Annotation { @@ -27,35 +55,48 @@ public static class Annotation { public String next = ""; } + /** + @param line the line of code to parse + @param start the starting comment delimiter + @param end the ending comment delimiter + @return the parsed annotation, or an empty annotation if none found + */ public static Annotation parse(String line, String start, String end) { Annotation ann = new Annotation(); int i = line.indexOf(start); if (i < 0) return ann; String before = line.substring(0, i); i += start.length(); - String line1 = line.stripTrailing(); - if (!line1.endsWith(end)) return ann; - int j = line1.length() - end.length(); - int k = i; - while (k < j && Character.isAlphabetic(line.charAt(k))) k++; - if (k < j && !Character.isWhitespace(line.charAt(k))) return ann; - String key = line.substring(i, k); - if (!VALID_ANNOTATIONS.contains(key)) return ann; + String line1 = line.substring(i).stripTrailing(); + if (end.length() > 0) { + if (!line1.endsWith(end)) return ann; + line1 = line1.substring(0, line1.length() - end.length()).stripTrailing(); + } + boolean found = false; + Iterator iter = VALID_ANNOTATIONS.iterator(); + String key = ""; + while (!found && iter.hasNext()) { + key = iter.next(); + if (line1.startsWith(key)) found = true; + } + if (!found) return ann; + if (key.length() < line1.length() && !Character.isWhitespace(line1.charAt(key.length()))) return ann; // Only a few annotations can have non-blank before if (!before.isBlank() && !NON_BLANK_BEFORE_OK.contains(key)) return ann; ann.isValid = true; ann.before = before; ann.key = key; - ann.args = line.substring(k, j).strip(); + ann.args = line1.substring(key.length()).strip(); return ann; } - private Language language; private List annotations = new ArrayList<>(); private Set keys = new HashSet<>(); private Set solutions = new TreeSet<>(); private Set hidden = new TreeSet<>(); + private Set hiddenCallFiles = new TreeSet<>(); + private Set hiddenTestFiles = new TreeSet<>(); public Annotations(Language language) { this.language = language; @@ -88,6 +129,12 @@ private void read(Path p, byte[] contents) { solutions.add(p); if (a.key.equals("HIDE")) hidden.add(p); + if (a.key.equals("CALL HIDDEN")) + hiddenCallFiles.add(p); + if (a.key.equals("HIDDEN")) { + hiddenTestFiles.add(p); + hidden.add(p); + } a.path = p; annotations.add(a); } @@ -102,7 +149,15 @@ public Set getHidden() { return Collections.unmodifiableSet(hidden); } - public String findUniqueKey(String key) { + public Set getHiddenCallFiles() { + return Collections.unmodifiableSet(hiddenCallFiles); + } + + public Set getHiddenTestFiles() { + return Collections.unmodifiableSet(hiddenTestFiles); + } + + public String findUnique(String key) { Annotation match = null; for (Annotation a : annotations) { if (a.key.equals(key)) { @@ -123,7 +178,7 @@ else if (!match.args.equals(a.args)) { return match == null ? null : match.args; } - public List findKeys(String key) { + public List findAll(String key) { List result = new ArrayList<>(); for (Annotation a : annotations) if (a.key.equals(key)) @@ -152,27 +207,24 @@ public double findUniqueDoubleKey(String key, double defaultValue) { public boolean checkConditions(Map submissionFiles, Report report) { + String[] delims = language.pseudoCommentDelimiters(); for (Annotation a : annotations) { boolean forbidden = a.key.equals("FORBIDDEN"); if (a.key.equals("REQUIRED") || forbidden) { + String nextLine = a.next; + String message = null; + if (nextLine.startsWith(delims[0]) && nextLine.endsWith(delims[1])) + message = nextLine.substring(delims[0].length(), nextLine.length() - delims[1].length()).trim(); StringBuilder contents = new StringBuilder(); for (String line : Util.lines(submissionFiles.get(a.path))) { - // TODO: Removing comments like this is language specific - contents.append(line.replaceAll("//.*$", "")); + String commentPattern = Pattern.quote(delims[0]) + ".*" + Pattern.quote(delims[1]); + contents.append(line.replaceAll(commentPattern, "")); contents.append(" "); } boolean found = Pattern.compile(a.args).matcher(contents).find(); - if (found == forbidden) { // found && forbidden || !found && required - String nextLine = a.next; - String[] delims = language.pseudoCommentDelimiters(); - String message; - if (nextLine.startsWith(delims[0]) && nextLine.endsWith(delims[1])) - message = nextLine.substring(delims[0].length(), nextLine.length() - delims[1].length()).trim(); - else - message = (forbidden ? "Found " : "Did not find ") + a.args; - report.error(a.path + ": " + message); - return false; - } + boolean passed = found != forbidden; // found and required or not found and forbidden + report.condition(passed, forbidden, a.path, a.args, message); + if (!passed) return false; } } return true; @@ -182,6 +234,10 @@ public boolean has(String key) { return keys.contains(key); } + /* + * TODO: It would be more robust if substitutions could only be + * in non-editable lines. + */ public Substitution findSubstitution() { Substitution sub = new Substitution(language); for (Annotation a : annotations) { @@ -191,6 +247,7 @@ public Substitution findSubstitution() { return sub; } + // TODO: SAMPLE legacy? /** * Checks if a path is a sample * @param p the path without student/solution directory @@ -205,9 +262,17 @@ public boolean isSample(Path p) { public Calls findCalls() { Calls calls = new Calls(language); - for (Annotation a : annotations) { - if (a.key.equals("CALL")) - calls.addCall(a.path, a.args, a.next); + int callNum = 0; + for (int a=0; a[A-Za-z][A-Za-z0-9_]*)=(?.*)$"); + @Override public Pattern variableDeclPattern() { return VARIABLE_PATTERN; } + + private static Pattern FUNCTION_PATTERN = Pattern.compile("^\\s*function\\s+(?[A-Za-z][A-Za-z0-9_]*).*"); + @Override + public String functionName(String declaration) { + Matcher matcher = FUNCTION_PATTERN.matcher(declaration); + if (matcher.matches()) return matcher.group("name"); + else return null; + } + + @Override + public Map writeTester(Path file, String contents, List calls, ResourceLoader resourceLoader) { + + String moduleName = moduleOf(file); + String result = contents + "\n"; + result += "case $1 in\n"; + for (int k = 0; k < calls.size(); k++) { + Calls.Call call = calls.get(k); + result += " " + (k + 1) + ")\n " + call.name + " " + call.args + "\n ;;\n"; + } + result += "esac\n"; + + Map paths = new HashMap<>(); + paths.put(pathOf(moduleName + "CodeCheck"), result); + return paths; + } +} diff --git a/app/com/horstmann/codecheck/CLanguage.java b/app/com/horstmann/codecheck/CLanguage.java index f5415ba0..915734f1 100644 --- a/app/com/horstmann/codecheck/CLanguage.java +++ b/app/com/horstmann/codecheck/CLanguage.java @@ -22,7 +22,7 @@ public boolean isSource(Path p) { private static Pattern MAIN_PATTERN = Pattern.compile("\\s*((int|void)\\s+)?main\\s*\\([^)]*\\)\\s*(\\{\\s*)?"); @Override public Pattern mainPattern() { return MAIN_PATTERN; } - private static Pattern VARIABLE_PATTERN = Pattern.compile(".*\\S\\s+(?[A-Za-z][A-Za-z0-9]*)(\\s*[*\\[\\]]+)?\\s*=\\s*(?[^;]+);.*"); + private static Pattern VARIABLE_PATTERN = Pattern.compile(".*\\S\\s+(?[A-Za-z][A-Za-z0-9_]*)(\\s*[*\\[\\]]+)?\\s*=\\s*(?[^;]+);.*"); @Override public Pattern variableDeclPattern() { return VARIABLE_PATTERN; } private static Pattern ERROR_PATTERN = Pattern.compile(".+/(?[^/]+\\.cpp):(?[0-9]+):(?[0-9]+): error: (?.+)"); diff --git a/app/com/horstmann/codecheck/CSharpLanguage.java b/app/com/horstmann/codecheck/CSharpLanguage.java index 22add934..08c1f2b3 100644 --- a/app/com/horstmann/codecheck/CSharpLanguage.java +++ b/app/com/horstmann/codecheck/CSharpLanguage.java @@ -7,12 +7,8 @@ public class CSharpLanguage implements Language { public String getExtension() { return "cs"; } - - private static String patternString = ".*\\S\\s+(\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)\\s*=\\s*(?[^;]+);.*"; - private static Pattern pattern = Pattern.compile(patternString); - - @Override - public Pattern variableDeclPattern() { - return pattern; - } + + private static Pattern VARIABLE_DECL_PATTERN = Pattern.compile( + ".*\\S\\s+(\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)\\s*=\\s*(?[^;]+);"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } } \ No newline at end of file diff --git a/app/com/horstmann/codecheck/CallMethod.java b/app/com/horstmann/codecheck/CallMethod.java index 21a6ac40..041f0142 100644 --- a/app/com/horstmann/codecheck/CallMethod.java +++ b/app/com/horstmann/codecheck/CallMethod.java @@ -174,7 +174,7 @@ public void run(Path dir, Report report, Score score) throws Exception { } finally { loader.close(); } - report.runTable(null, new String[] { "Arguments" }, args, actual, expected, outcomes); + report.runTable(null, new String[] { "Arguments" }, args, actual, expected, outcomes, null, null); } private boolean compare(Object a, Object b) { diff --git a/app/com/horstmann/codecheck/Calls.java b/app/com/horstmann/codecheck/Calls.java index e0f05fc3..25c43902 100644 --- a/app/com/horstmann/codecheck/Calls.java +++ b/app/com/horstmann/codecheck/Calls.java @@ -1,10 +1,8 @@ package com.horstmann.codecheck; -import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.Map; public class Calls { @@ -12,6 +10,16 @@ public class Call { String name; String args; List modifiers; + private boolean hidden = false; + + public boolean isHidden() { + return hidden; + } + + public void setHidden(boolean value) + { + hidden = value; + } } private Language language; @@ -34,6 +42,12 @@ public int getSize() { public Call getCall(int i) { return calls.get(i); } + + public List getCalls() { + if (lastGroup < calls.size() - 1) + throw new CodeCheckException("No function below CALL in " + file + "\n"); + return calls; + } public void addCall(Path file, String args, String next) { if (this.file == null) @@ -57,12 +71,4 @@ else if (!this.file.equals(file)) lastGroup = calls.size() - 1; } } - - public Map writeTester(Problem problem, ResourceLoader resourceLoader) throws IOException { - if (lastGroup < calls.size() - 1) - throw new CodeCheckException("No function below CALL in " + file + "\n"); - String contents = Util.getString(problem.getSolutionFiles(), file); - if (contents.isEmpty()) contents = Util.getString(problem.getUseFiles(), file); - return language.writeTester(file, contents, calls, resourceLoader); - } } diff --git a/app/com/horstmann/codecheck/CompareImages.java b/app/com/horstmann/codecheck/CompareImages.java index d68d3bc0..82ba396b 100644 --- a/app/com/horstmann/codecheck/CompareImages.java +++ b/app/com/horstmann/codecheck/CompareImages.java @@ -17,23 +17,28 @@ public static boolean isImage(String name) { return Arrays.asList(ImageIO.getReaderFileSuffixes()).contains(extension); } - public CompareImages(byte[] firstImage) throws IOException { - image1 = readImage(firstImage); + public CompareImages(byte[] firstImage) { + try { + image1 = readImage(firstImage); + } catch (IOException ex) { + image1 = null; + } } public void setOtherImage(byte[] p) throws IOException { - image2 = readImage(p); + try { + image2 = readImage(p); + } catch (IOException ex) { + image2 = null; + } } public BufferedImage first() { return image1; } public BufferedImage other() { return image2; } private static BufferedImage readImage(byte[] bytes) throws IOException { - try { - return ImageIO.read(new ByteArrayInputStream(bytes)); - } catch (Exception ex) { - throw new IOException("Image not readable"); - } + if (bytes == null) throw new IOException("null data"); + return ImageIO.read(new ByteArrayInputStream(bytes)); } public boolean getOutcome() { diff --git a/app/com/horstmann/codecheck/Comparison.java b/app/com/horstmann/codecheck/Comparison.java index 6595af00..a12ab554 100644 --- a/app/com/horstmann/codecheck/Comparison.java +++ b/app/com/horstmann/codecheck/Comparison.java @@ -26,12 +26,10 @@ public boolean execute(String input, String actual, String expected, Report repo matches.add(m); } if (outcome) { - if (filename != null) { + if (filename != null) report.file(filename, actual); - } - else { - report.output(actual); - } + else + report.output(actual); } else { // Print inputs which are getting replaced diff --git a/app/com/horstmann/codecheck/CppLanguage.java b/app/com/horstmann/codecheck/CppLanguage.java index 1bd77fb3..19640976 100644 --- a/app/com/horstmann/codecheck/CppLanguage.java +++ b/app/com/horstmann/codecheck/CppLanguage.java @@ -4,6 +4,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; @@ -33,74 +34,47 @@ public boolean isSource(Path p) { @Override public Map writeTester(Path file, String contents, List calls, ResourceLoader resourceLoader) throws IOException { - // function in solution needs to be in separate namespace - // function of student needs be externed; // imports on top // Look at line in solution file following //CALL // Remove any trailing {, add ; // add solution wrapped in namespace solution { ... } String moduleName = moduleOf(file); - List lines = Util.lines(contents); - int i = 0; - boolean done = false; - while (!done) { - if (i == lines.size()) done = true; - else { - String line = lines.get(i).trim(); - if (line.length() == 0 || line.startsWith("#include") || line.startsWith("using ") || line.startsWith("//")) i++; - else done = true; - } - } - - lines.add(i++, "#include \"codecheck.h\""); + List lines = new ArrayList<>(); + contents + .lines() + .filter(l -> l.trim().startsWith("#include ")) + .forEach(l -> lines.add(l)); + lines.add("#include \"codecheck.h\""); + lines.add("#include "); Set externs = new LinkedHashSet<>(); for (Calls.Call c : calls) externs.add(c.modifiers.get(0) + " " + c.modifiers.get(1) + ";"); - lines.add(i++, "namespace solution {"); - for (String extern : externs) { - lines.add(i++, extern); // extern function from solution - } - lines.add(i++, "}"); - lines.add(i++, "int main(int argc, char *argv[]) {"); - // We declare the student functions locally in main so that they don't conflict with - // solution functions for (String extern : externs) { - lines.add(i++, extern); // extern function from student + lines.add(extern); // extern function from student } + lines.add("int main(int argc, char *argv[]) {"); + lines.add(" int arg = std::atoi(argv[1]);"); for (int k = 0; k < calls.size(); k++) { Calls.Call call = calls.get(k); - lines.add(i++, - " if (codecheck::eq(argv[1], \"" + (k + 1) + "\")) {"); - lines.add(i++, - " codecheck::compare(solution::" + call.name + "(" + call.args + "), " + call.name + "(" + call.args + "));"); - // compare expected and actual - lines.add(i++, "}"); + lines.add(" if (arg == " + (k + 1) + ") {"); + lines.add(" codecheck::print(" + call.name + "(" + call.args + "));"); + lines.add("}"); } - lines.add(i++, " return 0;"); - lines.add(i++, "}"); - lines.add(i++, "namespace solution {"); + lines.add(" return 0;"); lines.add("}"); Map paths = new HashMap<>(); paths.put(pathOf(moduleName + "CodeCheck"), Util.join(lines, "\n")); + // TODO: Include these in the same file paths.put(Paths.get("codecheck.cpp"), resourceLoader.loadResourceAsString("codecheck.cpp")); paths.put(Paths.get("codecheck.h"), resourceLoader.loadResourceAsString("codecheck.h")); return paths; } - private static String patternString = ".*\\S\\s+(?[A-Za-z_][A-Za-z0-9_]*)(\\s*[*\\[\\]]+)?\\s*=\\s*(?[^;]+);.*"; - private static Pattern pattern = Pattern.compile(patternString); - - /* - * (non-Javadoc) - * - * @see com.horstmann.codecheck.Language#variablePattern() - */ - @Override - public Pattern variableDeclPattern() { - return pattern; - } + private static Pattern VARIABLE_DECL_PATTERN = Pattern.compile( + ".*\\S\\s+(?[A-Za-z_][A-Za-z0-9_]*)(\\s*[*\\[\\]]+)?\\s*=\\s*(?[^;]+);"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } @Override public List modifiers(String declaration) { diff --git a/app/com/horstmann/codecheck/DartLanguage.java b/app/com/horstmann/codecheck/DartLanguage.java index 0554b1e2..3915153d 100644 --- a/app/com/horstmann/codecheck/DartLanguage.java +++ b/app/com/horstmann/codecheck/DartLanguage.java @@ -5,6 +5,8 @@ import java.util.List; import java.util.Map; import java.util.regex.Pattern; +import java.util.ArrayList; + public class DartLanguage implements Language { @@ -14,51 +16,31 @@ public class DartLanguage implements Language { private static Pattern MAIN_PATTERN = Pattern.compile("\\s*((int|void)\\s+)?main\\s*\\([^)]*\\)\\s*(\\{\\s*)?"); @Override public Pattern mainPattern() { return MAIN_PATTERN; } - private static Pattern VARIABLE_PATTERN = Pattern.compile(".*\\S\\s+(?[A-Za-z][A-Za-z0-9]*)(\\s*[*\\[\\]]+)?\\s*=\\s*(?[^;]+);.*"); - @Override public Pattern variableDeclPattern() { return VARIABLE_PATTERN; } + private static Pattern VARIABLE_DECL_PATTERN + = Pattern.compile(".*\\S\\s+(?[A-Za-z][A-Za-z0-9_]*)(\\s*[*\\[\\]]+)?\\s*=\\s*(?[^;]+);"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } private static Pattern ERROR_PATTERN = Pattern.compile(".+/(?[^/]+\\.cpp):(?[0-9]+):(?[0-9]+): error: (?.+)"); @Override public Pattern errorPattern() { return ERROR_PATTERN; } - // TODO: Implement this + // TODO Add test case to samples @Override public Map writeTester(Path file, String contents, List calls, ResourceLoader resourceLoader) { - String moduleName = moduleOf(file); - String classname = moduleOf(file); - //List lines = Util.readLines(sourceDir.resolve(file)); - - List lines = Util.lines(contents); - int i = 0; - lines.add(i++, "import '" + moduleName + ".dart';"); - lines.add(i++, "main() {"); + List lines = new ArrayList<>(); + lines.add("import '" + moduleName + ".dart';"); + lines.add("main() {"); for (int k = 0; k < calls.size(); k++) { - Calls.Call call = calls.get(k); - lines.add(i++, " var expected = " - + call.name + "(" + call.args - + ");"); - lines.add(i++, " print(expected);"); - lines.add(i++, " var actual = " - + moduleName + "()." + call.name + "(" -// + call.name + "(" - - + call.args + ");"); - lines.add(i++, " print(actual);"); - lines.add(i++, " if (expected == actual) "); - lines.add(i++, " print(\"true\"); "); - lines.add(i++, " else "); - lines.add(i++, " print(\"false\"); "); + lines.add(" var result = " + moduleName + "()." + call.name + "(" + + call.args + ");"); + lines.add(" print(result);"); } - lines.add(i++, "}"); - //lines.add("main();"); - - Map paths = new HashMap<>(); - paths.put(pathOf(moduleName + "CodeCheck"), ""); + lines.add("}"); - Path p = pathOf(classname + "CodeCheck"); + Path p = pathOf(moduleName + "CodeCheck"); Map testFiles = new HashMap<>(); testFiles.put(p, Util.join(lines, "\n")); return testFiles; diff --git a/app/com/horstmann/codecheck/HTMLReport.java b/app/com/horstmann/codecheck/HTMLReport.java index 87501756..716752d6 100644 --- a/app/com/horstmann/codecheck/HTMLReport.java +++ b/app/com/horstmann/codecheck/HTMLReport.java @@ -3,8 +3,9 @@ import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; import java.util.List; @@ -16,7 +17,19 @@ public class HTMLReport implements Report { protected StringBuilder builder; private List footnotes = new ArrayList<>(); private int metaOffset; + private boolean hidden; + private static boolean isImage(String filename) { + return Set.of("png", "gif", "jpg", "jpeg", "bmp").contains(Util.extension(filename)); + } + + private static String imageData(String filename, byte[] contents) { + String extension = Util.extension(filename); + if (extension.equals("jpg")) extension = "jpeg"; + return "data:image/" + extension + ";base64," + Base64.getEncoder().encodeToString(contents); + } + + // TODO: Directory public HTMLReport(String title) { builder = new StringBuilder(); @@ -62,12 +75,15 @@ public HTMLReport header(String section, String text) { escape(text); builder.append("

\n"); } + hidden = false; return this; } @Override - public HTMLReport run(String text) { + public HTMLReport run(String text, String mainclass, boolean hidden) { + this.hidden = hidden; + caption(mainclass); if (text != null && !text.trim().equals("")) { builder.append("

"); escape(text); @@ -95,7 +111,10 @@ public HTMLReport output(CharSequence text) { if (text == null || text.equals("")) return this; builder.append("

");
-        escape(text);
+        if (hidden)
+        	builder.append("[Hidden]");
+        else
+        	escape(text);
         builder.append("
\n"); return this; } @@ -121,15 +140,19 @@ public Report footnote(String text) { public HTMLReport output(List lines, Set matches, Set mismatches) { builder.append("
");
-        for (int i = 0; i < lines.size(); i++) {
-            String line = lines.get(i);
-            if (matches.contains(i))
-                passSpan(line);
-            else if (mismatches.contains(i))
-                failSpan(line);
-            else
-                escape(line);
-            builder.append("\n");
+        if (hidden) {
+        	builder.append("[Hidden]");
+    	} else {
+	        for (int i = 0; i < lines.size(); i++) {
+	            String line = lines.get(i);
+	            if (matches.contains(i))
+	                passSpan(line);
+	            else if (mismatches.contains(i))
+	                failSpan(line);
+	            else
+	                escape(line);
+	            builder.append("\n");
+	        }
         }
         builder.append("
\n"); return this; @@ -166,33 +189,22 @@ public HTMLReport systemError(Throwable t) { * @see com.horstmann.codecheck.Report#image(java.lang.String, byte[]) */ @Override - public HTMLReport image(String captionText, BufferedImage img) throws IOException { + public HTMLReport image(String captionText, String filename, BufferedImage img) { if (img == null) return this; caption(captionText); - image(img); - return this; - } - - /* - * (non-Javadoc) - * - * @see com.horstmann.codecheck.Report#image(byte[]) - */ - @Override - public HTMLReport image(BufferedImage img) throws IOException { - if (img == null) - return this; - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ImageIO.write(img, "PNG", out); - out.close(); - byte[] pngBytes = out.toByteArray(); - String data = Base64.getEncoder().encodeToString(pngBytes); - builder.append("

"); - builder.append("\"screen"); - builder.append("

\n"); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(img, "PNG", out); + out.close(); + builder.append("

"); + builder.append("\"screen"); + builder.append("

\n"); + } catch (IOException ex) { + builder.append("

Cannot display image

"); + } return this; } @@ -203,6 +215,25 @@ public HTMLReport file(String file, String contents) { return this; } + public HTMLReport file(String fileName, byte[] contents, boolean hidden) { + caption(fileName); + if (hidden) { + output("[Hidden]"); + } else if (isImage(fileName)) { + builder.append("

\"provided

\n"); + } else { + try { + output(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(contents)).toString()); + } catch (CharacterCodingException e) { + output("[Binary]"); + } + } + return this; + } + + private HTMLReport escape(CharSequence s) { for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); @@ -280,19 +311,7 @@ protected void addFootnotes() { builder.append("\n"); } } - - /* - * (non-Javadoc) - * - * @see com.horstmann.codecheck.Report#save(java.nio.file.Path) - */ - @Override - public HTMLReport save(Path dir, String out) throws IOException { - Path outPath = dir.resolve(out + ".html"); - Files.write(outPath, builder.toString().getBytes()); - return this; - } - + @Override public void close() { addFootnotes(); @@ -301,6 +320,9 @@ public void close() { @Override public String getText() { return builder.toString(); } + + @Override + public String extension() { return "html"; } private HTMLReport tableStart(String klass) { builder.append(" matchData) { @Override public HTMLReport runTable(String[] methodNames, String[] argNames, String[][] args, - String[] actual, String[] expected, boolean[] outcomes) { + String[] actual, String[] expected, boolean[] outcomes, boolean[] hidden, String mainclass) { tableStart("run").rowStart(); headerCell(""); if (methodNames != null) @@ -441,12 +463,20 @@ public HTMLReport runTable(String[] methodNames, String[] argNames, String[][] a for (int i = 0; i < args.length; i++) { rowStart(); cellStart().pass(outcomes[i]).cellEnd(); - if (methodNames != null) - codeCell(methodNames[i]); - for (String a : args[i]) - codeCell(a.trim()); - codeCell(actual[i]); - codeCell(expected[i]); + if (hidden != null && hidden[i]) { + codeCell("[Hidden]"); + for (String a : args[i]) + codeCell("[Hidden]"); + codeCell("[Hidden]"); + codeCell("[Hidden]"); + } else { + if (methodNames != null) + codeCell(methodNames[i]); + for (String a : args[i]) + codeCell(a.trim()); + codeCell(actual[i]); + codeCell(expected[i]); + } rowEnd(); } tableEnd(); diff --git a/app/com/horstmann/codecheck/HaskellLanguage.java b/app/com/horstmann/codecheck/HaskellLanguage.java index 9b126c5b..83241d0e 100644 --- a/app/com/horstmann/codecheck/HaskellLanguage.java +++ b/app/com/horstmann/codecheck/HaskellLanguage.java @@ -36,27 +36,11 @@ public String functionName(String declaration) { @Override public Map writeTester(Path file, String contents, List calls, ResourceLoader resourceLoader) { Map result = new HashMap<>(); - - // Rewrite solution in module CodeCheckSolution String moduleName = moduleOf(file); - Path solutionModuleFile = Paths.get("CodeCheckSolution.hs"); - StringBuilder out = new StringBuilder(); - List in = Util.lines(contents); - out.append("module CodeCheckSolution where\n"); - for (int i = 0; i < in.size(); i++) { - String line = in.get(i); - if (!line.trim().startsWith("module ")) { - out.append(line); - out.append("\n"); - } - } - result.put(solutionModuleFile, out.toString()); - // Generate testCodeCheck.hs Path testFile = Paths.get("testCodeCheck.hs"); - out = new StringBuilder(); + StringBuilder out = new StringBuilder(); out.append("import " + moduleName + "\n"); - out.append("import CodeCheckSolution\n"); out.append("import Control.Exception\n"); out.append("import System.Environment\n"); out.append("main :: IO ()\n"); @@ -64,50 +48,28 @@ public String functionName(String declaration) { out.append(" args <- getArgs\n"); for (int k = 0; k < calls.size(); k++) { Calls.Call call = calls.get(k); - if (k == 0) out.append(" if (head args) == \"1\" then "); + if (k == 0 && calls.size() > 1) out.append(" if (head args) == \"1\" then "); else if (k < calls.size() - 1) out.append(" else if (head args) == \"" + (k + 1) + "\" then "); - else out.append(" else "); - out.append(moduleName + "." + call.name + " " + call.args + " `comp` CodeCheckSolution." - + call.name + " " + call.args + "\n"); + else if (calls.size() > 1) out.append(" else "); + else out.append(" "); + out.append("pr $ " + call.name + " " + call.args + "\n"); } out.append(" where\n"); out.append(" exec expr = (do x <- evaluate expr ; return $ Just x)\n"); out.append(" `catch` (\\(SomeException x) -> return Nothing)\n"); - out.append(" comp expr1 expr2 = do\n"); - out.append(" actual <- exec expr1\n"); - out.append(" expected <- exec expr2\n"); - out.append(" case (actual, expected) of\n"); - out.append(" (Nothing, Nothing) -> putStrLn \"error\\nerror\\ntrue\"\n"); - out.append(" (Just a, Just b) -> putStrLn$ (show a) ++ \"\\n\" ++ (show b) ++ (if a==b then \"\\ntrue\" else \"\\nfalse\")\n"); - out.append(" (Just a, Nothing) -> putStrLn $ (show a) ++ \"\\nerror\\nfalse\"\n"); - out.append(" (Nothing, Just b) -> putStrLn $ \"error\\n\" ++ (show b) ++ \"\\nfalse\"\n"); + out.append(" pr expr = do\n"); + out.append(" res <- exec expr\n"); + out.append(" case res of\n"); + out.append(" Nothing -> putStrLn \"error\"\n"); + out.append(" Just a -> putStrLn $ show a\n"); result.put(testFile, out.toString()); return result; } - /* - - - - Student.maxNum [] `comp` CodeCheckSolution.maxNum [] -- function name and args - Student.maxNum [1] `comp` CodeCheckSolution.maxNum [1] - Student.maxNum [1,2,3] `comp` CodeCheckSolution.maxNum [1,2,3] -*/ - - - private static String variablePatternString = "\\s*let\\s+(?\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)\\s*=\\s*(?.+)"; - private static Pattern variablePattern = Pattern.compile(variablePatternString); - - /* - * (non-Javadoc) - * - * @see com.horstmann.codecheck.Language#variablePattern() - */ - @Override - public Pattern variableDeclPattern() { - return variablePattern; - } + private static Pattern VARIABLE_DECL_PATTERN = Pattern.compile( + "\\s*let\\s+(?\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)\\s*=\\s*(?.+)"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } private static Pattern ERROR_PATTERN = Pattern.compile("(?[^/]+\\.rkt):(?[0-9]+):(?[0-9]+): (?.+)"); @Override public Pattern errorPattern() { return ERROR_PATTERN; } diff --git a/app/com/horstmann/codecheck/Input.java b/app/com/horstmann/codecheck/Input.java new file mode 100644 index 00000000..fa88aad8 --- /dev/null +++ b/app/com/horstmann/codecheck/Input.java @@ -0,0 +1,27 @@ +package com.horstmann.codecheck; + + +public class Input{ + private String key; + private String value; + private boolean hidden; + + + public Input(String key, String value, boolean hidden) { + this.key = key; + this.value = value; + this.hidden = hidden; + } + + public boolean getHidden(){ + return hidden; + } + + public String getKey() { + return key; + } + + public String getValue() { + return value; + } +} \ No newline at end of file diff --git a/app/com/horstmann/codecheck/JSONReport.java b/app/com/horstmann/codecheck/JSONReport.java index 27b68da1..56d927bd 100644 --- a/app/com/horstmann/codecheck/JSONReport.java +++ b/app/com/horstmann/codecheck/JSONReport.java @@ -3,9 +3,10 @@ import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.nio.file.Path; +import java.io.UncheckedIOException; import java.util.ArrayList; import java.util.Base64; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -13,36 +14,58 @@ import javax.imageio.ImageIO; -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - public class JSONReport implements Report { public static class Item { public Item() {} public Item(String name, String contents) { + this(name, contents, false); + } + public Item(String name, String contents, boolean hidden) { this.name = name; - this.value = contents; + this.value = contents; + this.hidden = hidden; } public String name; public String value; + public boolean hidden; } - + + public static class ImageItem { + public ImageItem() {} + public ImageItem(String caption, String filename, BufferedImage image) { + this.caption = caption; + name = filename; + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, "PNG", out); + out.close(); + data = Base64.getEncoder().encodeToString(out.toByteArray()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + public String caption; + public String name; + public String data; + } + public static class Run { public String caption; + public String mainclass; public List args; public String input; public String output; + public boolean hidden; public List matchedOutput; - public List files = new ArrayList<>(); - public List images = new ArrayList<>(); + public Map files = new HashMap<>(); + public List images = new ArrayList<>(); public String errors; public List errorData = new ArrayList<>(); public String html; public Boolean passed; } - public static class Section { + public static class Section { public String type; public String errors; public List errorData = new ArrayList<>(); @@ -51,23 +74,15 @@ public static class Section { public static class ReportData { public String errors; - //public List studentFiles = new ArrayList<>(); - //public List providedFiles = new ArrayList<>(); public List
sections = new ArrayList<>(); public Map metaData = new LinkedHashMap<>(); public String score; // TODO: Score each item } - private ReportData data = new ReportData(); + protected ReportData data = new ReportData(); private Section section; private Run run; - /* TODO: - * testMethod: - * call: - * sub: - */ - public JSONReport(String title) { } @@ -82,8 +97,10 @@ public JSONReport header(String sectionType, String text) { } @Override - public JSONReport run(String caption) { + public JSONReport run(String caption, String mainclass, boolean hidden) { run = new Run(); + run.mainclass = mainclass; + run.hidden = hidden; run.passed = true; if (section.runs == null) section.runs = new ArrayList<>(); section.runs.add(run); @@ -101,7 +118,10 @@ public JSONReport output(CharSequence text) { if (run.html != null) builder.append(run.html); builder.append("

Output:

"); builder.append("
");
-        builder.append(HTMLReport.htmlEscape(text));
+        if (run.hidden)
+        	builder.append("[Hidden]");
+        else
+        	builder.append(HTMLReport.htmlEscape(text));
         builder.append("
"); run.html = builder.toString(); @@ -143,8 +163,7 @@ public JSONReport systemError(Throwable t) { @Override public JSONReport args(String args) { - // TODO: Would like to skip if no args - // if (args == null || args.trim().length() == 0) return this; + if (args == null || args.trim().length() == 0) return this; run.args = new ArrayList<>(); run.args.add(new Item("Command line arguments", args)); return this; @@ -156,37 +175,28 @@ public JSONReport input(String input) { StringBuilder builder = new StringBuilder(); if (run.html != null) builder.append(run.html); if (run.input != null) { - builder.append("

Input:

");
-            builder.append(HTMLReport.htmlEscape(run.input));
-            builder.append("
"); + if (run.hidden) { + builder.append("
[Hidden]
"); + } else { + builder.append("

Input:

");
+	            builder.append(HTMLReport.htmlEscape(run.input));
+	            builder.append("
"); + } } run.html = builder.toString(); return this; } @Override - public JSONReport image(String caption, BufferedImage image) throws IOException { - if (image == null) return this; - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ImageIO.write(image, "PNG", out); - out.close(); - byte[] pngBytes = out.toByteArray(); - String data = Base64.getEncoder().encodeToString(pngBytes); - run.images.add(new Item(caption, data)); + public JSONReport image(String caption, String filename, BufferedImage image) { + if (image != null) run.images.add(new ImageItem(caption, filename, image)); return this; } - @Override - public JSONReport image(BufferedImage image) throws IOException { - image("", image); - return this; - } - @Override public JSONReport file(String file, String contents) { - Item item = new Item(file, contents); - if (!"studentFiles".equals(section.type) && !"providedFiles".equals(section.type)) { - run.files.add(item); + if (!"studentFiles".equals(section.type)) { + run.files.put(file, contents); StringBuilder builder = new StringBuilder(); if (run.html != null) builder.append(run.html); @@ -199,6 +209,11 @@ public JSONReport file(String file, String contents) { } return this; } + + public JSONReport file(String fileName, byte[] contents, boolean hidden) { + // Not reporting provided files + return this; + } @Override public JSONReport add(Score score) { @@ -207,24 +222,11 @@ public JSONReport add(Score score) { } @Override - public JSONReport save(Path dir, String out) throws IOException { - Path outPath = dir.resolve(out + ".json"); - ObjectMapper mapper = new ObjectMapper(); - mapper.setSerializationInclusion(Include.NON_DEFAULT); - mapper.writeValue(outPath.toFile(), data); - // JSON.std.write(data, outPath.toFile()); - return this; - } - + public String extension() { return "json"; } + @Override public String getText() { - ObjectMapper mapper = new ObjectMapper(); - mapper.setSerializationInclusion(Include.NON_DEFAULT); - try { - return mapper.writeValueAsString(data); - } catch (JsonProcessingException e) { - return null; - } + return Util.toJsonString(data); } @Override @@ -282,21 +284,25 @@ public JSONReport output(List lines, Set matches, StringBuilder builder = new StringBuilder(); if (run.html != null) builder.append(run.html); builder.append("
");
-        for (int i = 0; i < lines.size(); i++) {
-            StringBuilder line = HTMLReport.htmlEscape(lines.get(i));
-            if (matches.contains(i)) {
-                builder.append("");
-                builder.append(line);
-                builder.append("");
-            }
-            else if (mismatches.contains(i)) {
-                builder.append("");
-                builder.append(line);
-                builder.append("");                
-            }
-            else
-                builder.append(line);
-            builder.append("\n");
+        if (run.hidden) {
+            for (int i = 0; i < lines.size(); i++) {
+                StringBuilder line = HTMLReport.htmlEscape(lines.get(i));
+                if (matches.contains(i)) {
+                    builder.append("");
+                    builder.append(line);
+                    builder.append("");
+                }
+                else if (mismatches.contains(i)) {
+                    builder.append("");
+                    builder.append(line);
+                    builder.append("");                
+                }
+                else
+                    builder.append(line);
+                builder.append("\n");
+            }        	
+        } else {
+        	builder.append("[Hidden]");
         }
         builder.append("
\n"); run.html = builder.toString(); @@ -306,11 +312,13 @@ else if (mismatches.contains(i)) { @Override public JSONReport runTable(String[] methodNames, String[] argNames, String[][] args, String[] actual, - String[] expected, boolean[] outcomes) { + String[] expected, boolean[] outcomes, boolean[] hidden, String mainclass) { if (section.runs == null) section.runs = new ArrayList<>(); for (int i = 0; i < actual.length; i++) { Run run = new Run(); + run.mainclass = mainclass; + run.hidden = hidden != null && hidden[i]; run.passed = true; if (methodNames != null) run.caption = methodNames[i]; section.runs.add(run); @@ -372,8 +380,7 @@ public JSONReport footnote(String text) { } @Override - public JSONReport errors(List errorData) - { + public JSONReport errors(List errorData) { if (section != null) section.errorData.addAll(errorData); return this; diff --git a/app/com/horstmann/codecheck/JavaLanguage.java b/app/com/horstmann/codecheck/JavaLanguage.java index fda40a3a..8566d712 100644 --- a/app/com/horstmann/codecheck/JavaLanguage.java +++ b/app/com/horstmann/codecheck/JavaLanguage.java @@ -7,13 +7,14 @@ import java.util.List; import java.util.Map; import java.util.regex.Pattern; +import java.util.ArrayList; public class JavaLanguage implements Language { public String getExtension() { return "java"; }; @Override public boolean isUnitTest(Path fileName) { - return fileName.toString().matches(".*Test[0-9]*.java"); + return fileName.toString().matches(".*(T|_t)est[0-9]*.java"); } private static Pattern mainPattern = Pattern @@ -39,83 +40,50 @@ public Path pathOf(String moduleName) { @Override public Map writeTester(Path file, String contents, List calls, ResourceLoader resourceLoader) { String className = moduleOf(file); - List lines = Util.lines(contents); - int i = 0; - // TODO: Could be enum or record - while (i < lines.size() && !lines.get(i).contains("class " + className)) - i++; - if (i == lines.size()) - throw new CodeCheckException("Can't find class " + className - + " for inserting CALL in " + file); - lines.set(i, lines.get(i).replace("class " + className, - "class " + className + "CodeCheck { static class Solution { static class " + className)); - i = lines.size() - 1; - while (i >= 0 && !lines.get(i).trim().equals("}")) - i--; - if (i == -1) - throw new CodeCheckException("Can't find } for inserting CALL in " - + file); - lines.add(i++, " }"); - lines.add(i++, " }"); - lines.add(i++, " public static void main(String[] args) throws Exception"); - lines.add(i++, " {"); - for (int k = 0; k < calls.size(); k++) { - Calls.Call call = calls.get(k); - boolean isStatic = call.modifiers.contains("static"); - lines.add(i++, " if (args[0].equals(\"" + (k + 1) + "\"))"); - lines.add(i++, " {"); - if (!isStatic) { - lines.add(i++, " " + className + " obj1 = new " + className + List lines = new ArrayList<>(); + contents + .lines() + .filter(l -> l.trim().startsWith("import ")) + .forEach(l -> lines.add(l)); + lines.add("public class " + className + "CodeCheck {"); + lines.add(" public static void main(String[] args) throws Exception"); + lines.add(" {"); + for (int k = 0; k < calls.size(); k++) { + Calls.Call call = calls.get(k); + boolean isStatic = call.modifiers.contains("static"); + lines.add(" if (args[0].equals(\"" + (k + 1) + "\"))"); + lines.add(" {"); + if (!isStatic) { + lines.add(" " + className + " obj1 = new " + className + "();"); - lines.add(i++, " Solution." + className + " obj2 = new Solution." + className + "();"); - } - lines.add(i++, " Object expected = " - + (isStatic ? "Solution." + className : "obj2") + "." + call.name + "(" + call.args - + ");"); - lines.add(i++, - " System.out.println(_toString(expected));"); - lines.add(i++, " Object actual = " + } + lines.add(" Object result = " + (isStatic ? className : "obj1") + "." + call.name + "(" + call.args + ");"); - lines.add(i++, " System.out.println(_toString(actual));"); - lines.add( - i++, - " System.out.println(java.util.Objects.deepEquals(actual, expected));"); - lines.add(i++, " }"); + lines.add(" System.out.println(_toString(result));"); + lines.add(" }"); } - lines.add(i++, " }"); - lines.add(i++, " private static String _toString(Object obj)"); - lines.add(i++, " {"); - lines.add(i++, " if (obj == null) return \"null\";"); - lines.add(i++, " if (obj instanceof String) return \"\\\"\" + ((String) obj).replace(\"\\\\\", \"\\\\\\\\\").replace(\"\\\"\", \"\\\\\\\"\").replace(\"\\n\", \"\\\\n\") + \"\\\"\";"); - lines.add(i++, " if (obj instanceof Object[])"); - lines.add(i++, - " return java.util.Arrays.deepToString((Object[]) obj);"); - lines.add(i++, " if (obj.getClass().isArray())"); - lines.add( - i++, - " try { return (String) java.util.Arrays.class.getMethod(\"toString\", obj.getClass()).invoke(null, obj); }"); - lines.add(i++, " catch (Exception ex) {}"); - lines.add(i++, " return obj.toString();"); - lines.add(i++, " }"); - - // expected == null ? null : expected instanceof Object[] ? - // java.util.Arrays.deepToString((Object[]) expected) : - // expected.getClass().isArray() ? java.util.Arrays.toString(expected) : - // expected + lines.add(" }"); + lines.add(" private static String _toString(Object obj)"); + lines.add(" {"); + lines.add(" if (obj == null) return \"null\";"); + lines.add(" if (obj instanceof Object[])"); + lines.add(" return java.util.Arrays.deepToString((Object[]) obj);"); + lines.add(" if (obj.getClass().isArray())"); + lines.add(" try { return (String) java.util.Arrays.class.getMethod(\"toString\", obj.getClass()).invoke(null, obj); }"); + lines.add(" catch (Exception ex) {}"); + lines.add(" return obj.toString();"); + lines.add(" }"); + lines.add("}"); Path p = pathOf(className + "CodeCheck"); Map testFiles = new HashMap<>(); testFiles.put(p, Util.join(lines, "\n")); return testFiles; } - private static String patternString = "\\s*((public|static|final|private|protected)\\s+)*[A-Za-z0-9_<>\\[\\]]+\\s+(?\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)\\s*=\\s*(?[^;]+);.*"; - private static Pattern pattern = Pattern.compile(patternString); - - @Override - public Pattern variableDeclPattern() { - return pattern; - } + private static Pattern VARIABLE_DECL_PATTERN = Pattern.compile( + "((public|static|final|private|protected)\\s+)*[A-Za-z0-9_<>\\[\\]]+\\s+(?\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)\\s*=\\s*(?[^;]+);"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } @Override public String process(Path file, Map submissionFiles) { diff --git a/app/com/horstmann/codecheck/JavaScriptLanguage.java b/app/com/horstmann/codecheck/JavaScriptLanguage.java index 89a4547a..36ea55fd 100644 --- a/app/com/horstmann/codecheck/JavaScriptLanguage.java +++ b/app/com/horstmann/codecheck/JavaScriptLanguage.java @@ -2,7 +2,6 @@ import java.io.IOException; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; @@ -46,41 +45,20 @@ public Map writeTester(Path file, String contents, List call Set functionNames = new TreeSet<>(); for (Calls.Call call : calls) functionNames.add(call.name); List lines = new ArrayList<>(); - lines.add("const codecheck = require('./codecheck.js')"); // TODO: Update to ECMAScript module - lines.add("const studentFunctions = function(){"); lines.addAll(Util.lines(contents)); - lines.add("return {"); - for (String functionName : functionNames) - lines.add(functionName + ", "); - lines.add("}}()"); - lines.add("const solutionFunctions = function(){"); - lines.addAll(Util.lines(contents)); - lines.add("return {"); - for (String functionName : functionNames) - lines.add(functionName + ", "); - lines.add("}}()"); - for (int k = 0; k < calls.size(); k++) { Calls.Call call = calls.get(k); lines.add("if (process.argv[process.argv.length - 1] === '" + (k + 1) + "') {"); - lines.add("const actual = studentFunctions." + call.name + "(" + call.args + ")"); - lines.add("const expected = solutionFunctions." + call.name + "(" + call.args + ")"); - lines.add("console.log(JSON.stringify(expected))"); - lines.add("console.log(JSON.stringify(actual))"); - lines.add("console.log(codecheck.deepEquals(actual, expected))"); + lines.add("const result = " + call.name + "(" + call.args + ")"); + lines.add("console.log(JSON.stringify(result))"); lines.add("}"); } Map paths = new LinkedHashMap<>(); paths.put(pathOf(moduleName + "CodeCheck"), Util.join(lines, "\n")); - paths.put(Paths.get("codecheck.js"), resourceLoader.loadResourceAsString("codecheck.js")); // TODO mjs when we support ECMAScript modules return paths; } - private static String patternString = "(var|const|let)\\s+(?[A-Za-z][A-Za-z0-9]*)\\s*=(?[^;]+);?"; - private static Pattern pattern = Pattern.compile(patternString); - - @Override - public Pattern variableDeclPattern() { - return pattern; - } + private static Pattern VARIABLE_DECL_PATTERN = Pattern.compile( + "(var|const|let)\\s+(?[A-Za-z][A-Za-z0-9_]*)\\s*=(?[^;]+);?"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } } diff --git a/app/com/horstmann/codecheck/KotlinLanguage.java b/app/com/horstmann/codecheck/KotlinLanguage.java new file mode 100644 index 00000000..63dc37b3 --- /dev/null +++ b/app/com/horstmann/codecheck/KotlinLanguage.java @@ -0,0 +1,63 @@ +package com.horstmann.codecheck; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class KotlinLanguage implements Language { + + @Override + public String getExtension() { + return "kt"; + } + + private static Pattern mainPattern1 = Pattern + .compile("fun\\s+main\\s*\\((\\s*\\S+\\s*:\\s*Array<\\s*String\\s*>\\s*)?\\)"); + + @Override + public boolean isMain(Path p, String contents) { + return mainPattern1.matcher(contents).find(); + } + + @Override + public Map writeTester(Path file, String contents, List calls, ResourceLoader resourceLoader) { + // Requirement: File name must equal to object containing called method + String objectName = moduleOf(file); + List lines = new ArrayList<>(); + lines.add("fun main(args: Array) {"); + for (int k = 0; k < calls.size(); k++) { + Calls.Call call = calls.get(k); + String submissionFun = call.name; + lines.add("if (args[0] == \"" + (k + 1) + "\") {"); + lines.add(" val result = " + submissionFun + "(" + call.args + ")"); + lines.add(" println(result)"); + lines.add("}"); + } + lines.add("}"); + Path p = Paths.get(objectName + "CodeCheck.kt"); + Map testModules = new HashMap<>(); + testModules.put(p, Util.join(lines, "\n")); + return testModules; + } + + private static Pattern functionPattern = Pattern.compile( + "^\\s*fun\\s+(?\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)\\s*(.*).*$"); + + @Override public String functionName(String declaration) { + Matcher matcher = functionPattern.matcher(declaration); + if (matcher.matches()) return matcher.group("name"); + else return null; + } + + private static Pattern VARIABLE_DECL_PATTERN = Pattern.compile( + "(val|var)\\s+(?\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)(\\s*:[^=]+\\s*)?\\s*=\\s*(?[^;]+)"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } + + private static Pattern ERROR_PATTERN = Pattern.compile("(.+/)?(?[^/]+\\.kt):(?[0-9]+):(?[0-9]+): error: (?.+)"); + @Override public Pattern errorPattern() { return ERROR_PATTERN; } +} diff --git a/app/com/horstmann/codecheck/Language.java b/app/com/horstmann/codecheck/Language.java index 420bccba..ea4323c2 100644 --- a/app/com/horstmann/codecheck/Language.java +++ b/app/com/horstmann/codecheck/Language.java @@ -25,7 +25,11 @@ public interface Language { new CSharpLanguage(), new HaskellLanguage(), new SMLLanguage(), - new DartLanguage() + new DartLanguage(), + new RustLanguage(), + new BashLanguage(), + new KotlinLanguage(), + new PHPLanguage() }; static Language languageFor(Set files) { @@ -116,7 +120,7 @@ default boolean isTester(Path fileName) { if (fileName == null) return false; String moduleName = moduleOf(fileName); if (moduleName == null) return false; - return moduleName.matches(".*Tester[0-9]*"); + return moduleName.matches(".*(T|_t)ester[0-9]*"); } /** @@ -143,7 +147,7 @@ default boolean isMain(Path fileName, String contents) { if (!fileName.toString().endsWith("." + extension)) return false; Pattern p = mainPattern(); if (p == null) - return moduleOf(fileName).matches(".*(Runn|Test)er[0-9]*"); + return moduleOf(fileName).matches(".*(Runn|_runn|Test|_test)er[0-9]*"); else return p.matcher(contents).find(); } @@ -180,16 +184,16 @@ default Map writeTester(Path file, String contents, List submissionFiles, Substitution sub) throws Exception { @@ -109,65 +113,63 @@ private void doSubstitutions(Map submissionFiles, Substitution sub actual[i] = Util.truncate(actual[i], expected[i].length() + MUCH_LONGER); score.pass(outcomes[i], null); // Pass/fail shown in run table } - report.runTable(null, argNames, args, actual, expected, outcomes); + report.runTable(null, argNames, args, actual, expected, outcomes, null, mainFile.toString()); }); } + + private void doCalls(Map submissionFiles, Calls calls, ResourceLoader resourceLoader) throws Exception { + Path file = calls.getFile(); + + String submissionContents = submissionFiles.get(file); + if (submissionContents.isEmpty()) submissionContents = Util.getString(problem.getUseFiles(), file); + Map submissionTesterFiles = problem.getLanguage().writeTester(file, submissionContents, calls.getCalls(), resourceLoader); + Path submissionBase = Paths.get("submissioncallfiles"); + for (Map.Entry entry : submissionTesterFiles.entrySet()) + plan.addFile(submissionBase.resolve(entry.getKey()), entry.getValue()); + List submissionSources = new ArrayList(submissionTesterFiles.keySet()); - private void doCalls(Calls calls, ResourceLoader resourceLoader) throws Exception { - Map testerFiles = calls.writeTester(problem, resourceLoader); - Path base = Paths.get("callfiles"); - for (Map.Entry entry : testerFiles.entrySet()) - plan.addFile(base.resolve(entry.getKey()), entry.getValue()); + String solutionContents = Util.getString(problem.getSolutionFiles(), file); + if (solutionContents.isEmpty()) solutionContents = Util.getString(problem.getUseFiles(), file); + Map solutionTesterFiles = problem.getLanguage().writeTester(file, solutionContents, calls.getCalls(), resourceLoader); + Path solutionBase = Paths.get("solutioncallfiles"); + for (Map.Entry entry : solutionTesterFiles.entrySet()) + plan.addFile(solutionBase.resolve(entry.getKey()), entry.getValue()); + List solutionSources = new ArrayList(solutionTesterFiles.keySet()); + + // TODO: This is the only place with a list of sources. + // If the multiple sources in the C++ tester are removed, do we still need them? + plan.compile("submissioncall", "submission submissioncallfiles", submissionSources, dependentSourcePaths); + plan.compile("solutioncall", "solution solutioncallfiles", solutionSources, dependentSourcePaths); - String[] names = new String[calls.getSize()]; - String[][] args = new String[calls.getSize()][1]; - String[] actual = new String[calls.getSize()]; - String[] expected = new String[calls.getSize()]; - boolean[] outcomes = new boolean[calls.getSize()]; - int timeout = timeoutMillis / calls.getSize(); int maxOutput = maxOutputLen / calls.getSize(); - List sources = new ArrayList(testerFiles.keySet()); - plan.compile("call", "submission callfiles", sources, dependentSourcePaths); // TODO: This is the only place with a list of sources. Why??? for (int i = 0; i < calls.getSize(); i++) { - Path mainFile = sources.get(0); - // TODO: Solution code not isolated from student. It would be more secure to - // change call strategy to generate output. - plan.run("call", "call", "call" + i, mainFile, "", "" + (i + 1), timeout, maxOutput, false); + plan.run("submissioncall", "submissioncall", "submissioncall" + i, submissionSources.get(0), "", "" + (i + 1), timeout, maxOutput, false); + plan.run("solutioncall", "solutioncall", "solutioncall" + i, solutionSources.get(0), "", "" + (i + 1), timeout, maxOutput, false); } plan.addTask(() -> { - report.header("call", "Calling with Arguments"); - if (!plan.checkCompiled("call", report, score)) return; + report.header("call", "Calling with arguments"); + if (!plan.checkCompiled("submissioncall", report, score)) return; + if (!plan.checkCompiled("solutioncall", report, score)) return; + String[] names = new String[calls.getSize()]; + String[][] args = new String[calls.getSize()][1]; + String[] actual = new String[calls.getSize()]; + String[] expected = new String[calls.getSize()]; + boolean[] outcomes = new boolean[calls.getSize()]; + boolean[] hidden = new boolean[calls.getSize()]; + for (int i = 0; i < calls.getSize(); i++) { - String output = plan.outerr("call" + i); - List lines = Util.lines(output); + actual[i] = plan.outerr("submissioncall" + i); + expected[i] = plan.outerr("solutioncall" + i); + outcomes[i] = actual[i].equals(expected[i]); Calls.Call call = calls.getCall(i); names[i] = call.name; args[i][0] = call.args; - if (lines.size() == 3 && Arrays.asList("true", "false").contains(lines.get(2))) { - expected[i] = lines.get(0); - actual[i] = Util.truncate(lines.get(1), expected[i].length() + MUCH_LONGER); - outcomes[i] = lines.get(2).equals("true"); - } else { - // Error in compilation or execution - // We assume that the solution correctly produces a single line - // Most likely, the rest is an exception report, and the true/false never came - StringBuilder msg = new StringBuilder(); - for (int j = 1; j < lines.size(); j++) { - String line = lines.get(j); - if (j < lines.size() - 1 || !(line.equals("true") || line.equals("false"))) - msg.append(line); msg.append('\n'); - } - String message = msg.toString(); - - expected[i] = lines.size() > 0 ? lines.get(0) : ""; - actual[i] = message; - outcomes[i] = false; - } + hidden[i] = call.isHidden(); score.pass(outcomes[i], null /* no report--it's in the table */); } - report.runTable(names, new String[] { "Arguments" }, args, actual, expected, outcomes); + report.runTable(names, new String[] { "Arguments" }, args, actual, expected, outcomes, hidden, submissionSources.get(0).toString()); }); } @@ -178,16 +180,17 @@ private void runUnitTests() { unitTests.add(p); } if (unitTests.size() > 0) { - report.header("unitTest", "Unit Tests"); + plan.addTask(() -> report.header("unitTest", "Unit tests")); for (Path p: unitTests) { String id = plan.nextID("test"); plan.unitTest(id, p, dependentSourcePaths, timeoutMillis / unitTests.size(), maxOutputLen / unitTests.size()); plan.addTask(() -> { - report.run(p.toString()); + boolean hidden = problem.getAnnotations().getHiddenTestFiles().contains(p); + report.run(p.toString(), p.toString(), hidden); if (!plan.checkCompiled(id, report, score)) return; String outerr = plan.outerr(id); - problem.getLanguage().reportUnitTest(outerr, report, score); + problem.getLanguage().reportUnitTest(outerr, report, score); }); } @@ -199,7 +202,8 @@ private void runTester(Path mainFile, int timeout, int maxOutputLen) throws Exce plan.compile(compileID, "submission", mainFile, dependentSourcePaths); plan.run(compileID, compileID, mainFile, "", null, timeout, maxOutputLen, false); plan.addTask(() -> { - report.run("Running " + mainFile); + boolean hidden = problem.getAnnotations().getHiddenTestFiles().contains(mainFile); + report.run(mainFile.toString(), mainFile.toString(), hidden); if (!plan.checkCompiled(compileID, report, score)) return; String outerr = plan.outerr(compileID); AsExpected cond = new AsExpected(comp); @@ -210,56 +214,60 @@ private void runTester(Path mainFile, int timeout, int maxOutputLen) throws Exce }); } - private void testInputs(Map inputs, Path mainFile, boolean okToInterleave) throws Exception { + private void testInputs(List inputs, Path mainFile, boolean okToInterleave) throws Exception { /* * If there are no inputs, we feed in one empty input to execute the program. */ if (inputs.size() == 0) - inputs.put("", ""); + inputs.add(new Input("", "", false)); plan.compile("submissionrun", "submission", mainFile, dependentSourcePaths); - boolean runSolution = !problem.getInputMode() && !problem.getAnnotations().isSample(mainFile); - if (runSolution) + boolean runSolution = !problem.getInputMode() && !problem.getAnnotations().isSample(mainFile); // TODO: SAMPLE legacy? + if (runSolution) { plan.compile("solutionrun", "solution", mainFile, dependentSourcePaths); + } plan.addTask(() -> { - report.header("run", problem.getInputMode() ? "Output" : "Testing " + mainFile); if (runSolution) plan.checkSolutionCompiled("solutionrun", report, score); plan.checkCompiled("submissionrun", report, score); }); - for (String test : inputs.keySet()) { - String input = inputs.get(test); - testInput(mainFile, runSolution, test, input, timeoutMillis / inputs.size(), maxOutputLen / inputs.size(), okToInterleave); + for (int i = 0; i < inputs.size(); i++) { + String test = inputs.get(i).getKey(); + String input = inputs.get(i).getValue(); + boolean hidden = inputs.get(i).getHidden(); + testInput(mainFile, runSolution, test, input, timeoutMillis / inputs.size(), maxOutputLen / inputs.size(), okToInterleave, hidden); } } private void testInput(Path mainFile, - boolean runSolution, String test, String input, int timeout, int maxOutput, boolean okToInterleave) + boolean runSolution, String test, String input, int timeout, int maxOutput, boolean okToInterleave, boolean hidden) throws Exception { - List runargs = problem.getAnnotations().findKeys("ARGS"); + List runargs = problem.getAnnotations().findAll("ARGS"); if (runargs.size() == 0) runargs.add(""); - String out = problem.getAnnotations().findUniqueKey("OUT"); + String out = problem.getAnnotations().findUnique("OUT"); List outFiles = out == null ? Collections.emptyList() : Arrays.asList(out.trim().split("\\s+")); String runNumber = test.replace("test", "").trim(); - plan.addTask(() -> { - if (!plan.compiled("submissionrun")) return; - report.run(!test.equals("Input") && runNumber.length() > 0 ? "Test " + runNumber : null); - }); + String title = !test.equals("Input") && runNumber.length() > 0 ? "Test " + runNumber : null; boolean interleaveio = okToInterleave && (problem.getLanguage().echoesStdin() == Language.Interleave.ALWAYS || problem.getLanguage().echoesStdin() == Language.Interleave.UNGRADED && test.equals("Input")); if (input == null || input.isBlank()) interleaveio = false; else if (!input.endsWith("\n")) input += "\n"; // TODO: Language settings + // TODO: Pass runSolution to report? for (String args : runargs) { - testInput(mainFile, runSolution, test, input, args, outFiles, timeout / runargs.size(), maxOutput / runargs.size(), interleaveio); + plan.addTask(() -> { + if (!plan.compiled("submissionrun")) return; + report.run(title, mainFile.toString(), hidden); + }); + testInput(mainFile, runSolution, test, input, args, outFiles, timeout / runargs.size(), maxOutput / runargs.size(), interleaveio, hidden) ; } } private void testInput(Path mainFile, - boolean runSolution, String test, String input, String runargs, List outFiles, int timeout, int maxOutput, boolean interleaveio) + boolean runSolution, String test, String input, String runargs, List outFiles, int timeout, int maxOutput, boolean interleaveio, boolean hidden) throws Exception { String submissionRunID = plan.nextID("submissionrun"); plan.run("submissionrun", submissionRunID, mainFile, runargs, input, outFiles, timeout, maxOutput, interleaveio); @@ -272,71 +280,60 @@ private void testInput(Path mainFile, report.args(runargs); if (!interleaveio && !test.equals("Input")) report.input(input); - - List contents = new ArrayList<>(); - List imageComp = new ArrayList<>(); + String outerr = plan.outerr(submissionRunID); + String expectedOuterr = runSolution ? plan.outerr(solutionRunID) : null; + if (expectedOuterr != null && expectedOuterr.trim().length() > 0 && outFiles.size() == 0) { + boolean outcome = comp.execute(input, outerr, expectedOuterr, report, null); + score.pass(outcome, report); + } else { + // Not scoring output if there are outFiles, but showing in case there is an exception + report.output(outerr); + } + + Map contents = new HashMap<>(); + Map imageComp = new HashMap<>(); for (String f : outFiles) { if (CompareImages.isImage(f)) { - try { - imageComp.add(new CompareImages(plan.getOutputBytes(submissionRunID, f))); - } catch (IOException ex) { - report.output(outerr); - report.error(ex.getMessage()); - } + imageComp.put(f, new CompareImages(plan.getOutputBytes(submissionRunID, f))); } else - contents.add(plan.getOutputString(submissionRunID, f)); + contents.put(f, plan.getOutputString(submissionRunID, f)); } if (!runSolution) { - report.output(outerr); for (String f : outFiles) { if (CompareImages.isImage(f)) { - try { - report.image("Image", imageComp.remove(0).first()); - } catch (IOException ex) { - report.error(ex.getMessage()); - } + CompareImages ci = imageComp.get(f); + report.image(null, f, ci.first()); } else - report.file(f, contents.remove(0)); + report.file(f, contents.get(f)); } // No score return; } - String expectedOuterr = plan.outerr(solutionRunID); - - if (expectedOuterr != null && expectedOuterr.trim().length() > 0) { - if (outFiles.size() > 0) { - // Report output but don't grade it - report.output(outerr); - } else { - boolean outcome = comp.execute(input, outerr, expectedOuterr, report, null); - score.pass(outcome, report); - } - } - for (String f : outFiles) { if (CompareImages.isImage(f)) { - CompareImages ic = imageComp.remove(0); + CompareImages ic = imageComp.get(f); try { ic.setOtherImage(plan.getOutputBytes(solutionRunID, f)); boolean outcome = ic.getOutcome(); - report.image("Image", ic.first()); - if (!outcome) { - report.image("Expected", ic.other()); - report.image("Mismatched pixels", ic.diff()); + if (outcome) { + report.image(outFiles.size() > 1 ? f : null, f, ic.first()); + } else { + report.image(outFiles.size() > 1 ? f : "Actual", f, ic.first()); + report.image("Expected", null, ic.other()); + report.image("Mismatched pixels", null, ic.diff()); } score.pass(outcome, report); } catch (IOException ex) { report.error(ex.getMessage()); } - } else { + } else { // TODO: Should we support binary files??? String expectedContents = plan.getOutputString(solutionRunID, f); - boolean outcome = comp.execute(input, contents.remove(0), - expectedContents, report, f); + boolean outcome = comp.execute(input, contents.get(f), expectedContents, report, f); score.pass(outcome, report); } } @@ -400,10 +397,11 @@ public void reportComments(Properties metadata) { report.comment(entries.getKey().toString(), entries.getValue().toString()); } - public Report run(Map submissionFiles, Map problemFiles, + public Plan run(Map submissionFiles, Map problemFiles, String reportType, Properties metadata, ResourceLoader resourceLoader) throws IOException { long startTime = System.currentTimeMillis(); - boolean scoring = true; + boolean okToInterleave = true; + boolean scoring = true; // TODO: Legacy? try { // Set up report first in case anything else throws an exception @@ -413,45 +411,54 @@ else if ("JSON".equals(reportType)) report = new JSONReport("Report"); else if ("NJS".equals(reportType)) report = new NJSReport("Report"); + else if ("Setup".equals(reportType)) { + report = new SetupReport("Report"); + // okToInterleave = false; // TODO + } else report = new HTMLReport("Report"); + plan = new Plan(resourceLoader.getProperty("com.horstmann.codecheck.debug") != null); + plan.setReport(report); + plan.readSolutionOutputs(problemFiles); + problem = new Problem(problemFiles); + if (report instanceof SetupReport) ((SetupReport) report).setProblem(problem); + plan.setLanguage(problem.getLanguage()); - plan = new Plan(problem.getLanguage(), resourceLoader.getProperty("com.horstmann.codecheck.debug") != null); + Set printFiles = Util.filterNot(problem.getUseFiles().keySet(), "*.jar", "*.pdf"); // TODO pdf??? + printFiles.removeAll(problem.getSolutionFiles().keySet()); timeoutMillis = (int) problem.getAnnotations().findUniqueDoubleKey("TIMEOUT", DEFAULT_TIMEOUT_MILLIS); maxOutputLen = (int) problem.getAnnotations().findUniqueDoubleKey("MAXOUTPUTLEN", DEFAULT_MAX_OUTPUT_LEN); double tolerance = problem.getAnnotations().findUniqueDoubleKey("TOLERANCE", DEFAULT_TOLERANCE); - boolean ignoreCase = !"false".equalsIgnoreCase(problem.getAnnotations().findUniqueKey("IGNORECASE")); - boolean ignoreSpace = !"false".equalsIgnoreCase(problem.getAnnotations().findUniqueKey("IGNORESPACE")); - boolean okToInterleave = !"false".equalsIgnoreCase(problem.getAnnotations().findUniqueKey("INTERLEAVE")); - scoring = !"false".equalsIgnoreCase(problem.getAnnotations().findUniqueKey("SCORING")); + boolean ignoreCase = !"false".equalsIgnoreCase(problem.getAnnotations().findUnique("IGNORECASE")); + boolean ignoreSpace = !"false".equalsIgnoreCase(problem.getAnnotations().findUnique("IGNORESPACE")); + if ("false".equalsIgnoreCase(problem.getAnnotations().findUnique("SCORING"))) scoring = false; // TODO: Legacy? + if ("false".equalsIgnoreCase(problem.getAnnotations().findUnique("INTERLEAVE"))) okToInterleave = false; comp.setTolerance(tolerance); comp.setIgnoreCase(ignoreCase); - comp.setIgnoreSpace(ignoreSpace); - + comp.setIgnoreSpace(ignoreSpace); + + if (tolerance != DEFAULT_TOLERANCE) report.attribute("tolerance", tolerance); + if (timeoutMillis != DEFAULT_TIMEOUT_MILLIS) report.attribute("timeout", timeoutMillis); + if (maxOutputLen != DEFAULT_MAX_OUTPUT_LEN) report.attribute("maxOutputLen", maxOutputLen); + if (ignoreCase == false) report.attribute("ignoreCase", ignoreCase); + if (ignoreSpace == false) report.attribute("ignoreSpace", ignoreSpace); + if (okToInterleave == false) report.attribute("interleave", okToInterleave); + getMainAndDependentSourceFiles(); reportComments(metadata); copyFilesToPlan(submissionFiles); - - // TODO: This would be nice to have in Problem, except that one might later need to remove checkstyle.xml - // the use files that the students are entitled to see - Set printFiles = Util.filterNot(problem.getUseFiles().keySet(), - "*.png", "*.PNG", "*.gif", "*.GIF", "*.jpg", "*.jpeg", "*.JPG", "*.bmp", "*.BMP", - "*.jar", "*.pdf"); - printFiles.removeAll(problem.getAnnotations().getHidden()); - printFiles.removeAll(problem.getSolutionFiles().keySet()); - if (problem.getAnnotations().checkConditions(submissionFiles, report)) { - if (problem.getAnnotations().has("CALL")) { + if (problem.getAnnotations().has("CALL") || problem.getAnnotations().has("CALL HIDDEN")) { Calls calls = problem.getAnnotations().findCalls(); mainSourcePaths.remove(calls.getFile()); dependentSourcePaths.add(calls.getFile()); - doCalls(calls, resourceLoader); + doCalls(submissionFiles, calls, resourceLoader); } if (problem.getAnnotations().has("SUB")) { Substitution sub = problem.getAnnotations().findSubstitution(); @@ -460,10 +467,10 @@ else if ("NJS".equals(reportType)) doSubstitutions(submissionFiles, sub); } - Map inputs = new TreeMap<>(); + List inputs = new ArrayList(); for (String i : new String[] { "", "1", "2", "3", "4", "5", "6", "7", "8", "9" }) { String key = "test" + i + ".in"; - + Path p = Paths.get(key); byte[] contents = null; if (problemFiles.containsKey(p)) @@ -474,15 +481,18 @@ else if ("NJS".equals(reportType)) contents = problemFiles.get(p); } if (contents != null) - inputs.put("test" + i, new String(contents, StandardCharsets.UTF_8)); + inputs.add(new Input("test" + i, new String(contents, StandardCharsets.UTF_8), false)); } int inIndex = inputs.size(); - for (String s : problem.getAnnotations().findKeys("IN")) { - inputs.put("test" + ++inIndex, Util.unescapeJava(s)); + for (String s : problem.getAnnotations().findAll("IN")) { + inputs.add(new Input("test" + ++inIndex, Util.unescapeJava(s), false)); + } + for (String s : problem.getAnnotations().findAll("IN HIDDEN")) { + inputs.add(new Input("test" + ++inIndex, Util.unescapeJava(s), true)); } if (problem.getInputMode()) { Path p = Paths.get("Input"); - inputs.put("Input", submissionFiles.get(p)); + inputs.add(new Input("Input", submissionFiles.get(p), false)); } runUnitTests(); @@ -491,19 +501,20 @@ else if ("NJS".equals(reportType)) List runFiles = new ArrayList<>(); for (Path mainSourceFile : mainSourcePaths) { if (problem.getLanguage().isTester(mainSourceFile) && !problem.getSolutionFiles().keySet().contains(mainSourceFile) - && !problem.getAnnotations().isSample(mainSourceFile) && !problem.getInputMode()) + && !problem.getAnnotations().isSample(mainSourceFile) && !problem.getInputMode()) // TODO: SAMPLE legacy? testerFiles.add(mainSourceFile); else runFiles.add(mainSourceFile); } if (testerFiles.size() > 0) { - report.header("tester", "Testers"); + plan.addTask(() -> report.header("tester", "Running test")); for (Path testerFile : testerFiles) runTester(testerFile, timeoutMillis / testerFiles.size(), maxOutputLen / testerFiles.size()); } if (runFiles.size() > 0) { + plan.addTask(() -> report.header("run", "Running program")); for (Path runFile : runFiles) testInputs(inputs, runFile, okToInterleave); } @@ -520,11 +531,11 @@ else if ("NJS".equals(reportType)) }); } } + String remoteURL = resourceLoader.getProperty("com.horstmann.codecheck.comrun.remote"); + String scriptCommand = resourceLoader.getProperty("com.horstmann.codecheck.comrun.local"); + if (remoteURL == null && scriptCommand == null) scriptCommand = "/opt/codecheck/comrun"; + plan.execute(report, remoteURL, scriptCommand); } - String remoteURL = resourceLoader.getProperty("com.horstmann.codecheck.comrun.remote"); - String scriptCommand = resourceLoader.getProperty("com.horstmann.codecheck.comrun.local"); - if (remoteURL == null && scriptCommand == null) scriptCommand = "/opt/codecheck/comrun"; - plan.execute(report, remoteURL, scriptCommand); if (!problem.getInputMode()) { // Don't print submitted or provided files for run-only mode report.header("studentFiles", "Submitted files"); @@ -537,8 +548,11 @@ else if ("NJS".equals(reportType)) if (printFiles.size() > 0) { report.header("providedFiles", "Provided files"); - for (Path p : printFiles) - report.file(p.toString(), new String(problem.getUseFiles().get(p), StandardCharsets.UTF_8)); + for (Path p : printFiles) { + boolean hidden = problem.getAnnotations().getHidden().contains(p) + || problem.getAnnotations().getHiddenTestFiles().contains(p); + report.file(p.toString(), problem.getUseFiles().get(p), hidden); + } } } } catch (Throwable t) { @@ -554,6 +568,6 @@ else if ("NJS".equals(reportType)) } else System.err.println("report is null"); } - return report; + return plan; } } diff --git a/app/com/horstmann/codecheck/MatlabLanguage.java b/app/com/horstmann/codecheck/MatlabLanguage.java index bc7a31c2..8fc1f49a 100644 --- a/app/com/horstmann/codecheck/MatlabLanguage.java +++ b/app/com/horstmann/codecheck/MatlabLanguage.java @@ -47,27 +47,9 @@ public Map writeTester(Path file, String contents, List[A-Za-z][A-Za-z0-9_]*)\\s*=\\s*(?.+)"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } } diff --git a/app/com/horstmann/codecheck/NJSReport.java b/app/com/horstmann/codecheck/NJSReport.java index 9f8a5f0c..7072e2d0 100644 --- a/app/com/horstmann/codecheck/NJSReport.java +++ b/app/com/horstmann/codecheck/NJSReport.java @@ -1,16 +1,10 @@ package com.horstmann.codecheck; -import java.io.IOException; -import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - public class NJSReport extends HTMLReport { public static class ReportData { public List errors = new ArrayList<>(); @@ -58,26 +52,14 @@ public NJSReport errors(List errors) { return this; } - @Override - public NJSReport save(Path dir, String out) throws IOException { - Path outPath = dir.resolve(out + ".json"); - ObjectMapper mapper = new ObjectMapper(); - mapper.setSerializationInclusion(Include.NON_DEFAULT); - mapper.writeValue(outPath.toFile(), data); - return this; - } - @Override public String getText() { - ObjectMapper mapper = new ObjectMapper(); - mapper.setSerializationInclusion(Include.NON_DEFAULT); - try { - return mapper.writeValueAsString(data); - } catch (JsonProcessingException e) { - return null; - } + return Util.toJsonString(data); } + @Override + public String extension() { return "json"; } + @Override public void close() { if (sectionType != null) builder.append("\n"); diff --git a/app/com/horstmann/codecheck/PHPLanguage.java b/app/com/horstmann/codecheck/PHPLanguage.java new file mode 100644 index 00000000..88b7648d --- /dev/null +++ b/app/com/horstmann/codecheck/PHPLanguage.java @@ -0,0 +1,58 @@ +package com.horstmann.codecheck; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; + +import com.horstmann.codecheck.Calls.Call; + +public class PHPLanguage implements Language { + @Override + public String getExtension() { + return "php"; + } + + @Override + public Map writeTester(Path file, String contents, List calls, ResourceLoader resourceLoader) throws IOException { + String moduleName = moduleOf(file); + Set functionNames = new TreeSet<>(); + for (Calls.Call call : calls) functionNames.add(call.name); + List lines = new ArrayList<>(); + lines.addAll(Util.lines(contents)); + for (int i = lines.size() - 1; i >= 0; i--) { + if (lines.get(i).strip().equals("?>")) { + lines.set(i, ""); + i = 0; + } + } + for (int k = 0; k < calls.size(); k++) { + Calls.Call call = calls.get(k); + lines.add("if ($argv[1] === '" + (k + 1) + "') {"); + lines.add("$result = " + call.name + "(" + call.args + ");"); + lines.add("var_export($result);"); // TODO Or var_dump, print_r + lines.add("}"); + } + lines.add("?>"); + Map paths = new LinkedHashMap<>(); + paths.put(pathOf(moduleName + "CodeCheck"), Util.join(lines, "\n")); + return paths; + } + + private static Pattern VARIABLE_DECL_PATTERN = Pattern.compile( + "^\\s*(?\\$[A-Za-z][A-Za-z0-9_]*)\\s*=(?[^;]+);"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } + + public boolean isUnitTest(Path fileName) { return fileName.toString().matches(".*(T|_t)est[0-9]*.php"); } + + private static final Pattern successPattern = Pattern.compile("OK \\([0-9]+ tests?, (?[0-9.]+) assertions\\)"); + private static final Pattern failurePattern = Pattern.compile("Tests: [0-9]+, Assertions: (?[0-9]+), Failures: (?[0-9]+)\\."); + @Override public Pattern unitTestSuccessPattern() { return successPattern; } + @Override public Pattern unitTestFailurePattern() { return failurePattern; } + +} diff --git a/app/com/horstmann/codecheck/Plan.java b/app/com/horstmann/codecheck/Plan.java index 5f64c256..ebe43ec7 100644 --- a/app/com/horstmann/codecheck/Plan.java +++ b/app/com/horstmann/codecheck/Plan.java @@ -10,9 +10,11 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; +import java.util.Set; public class Plan { private Language language; @@ -23,13 +25,57 @@ public class Plan { private int nextID = 0; private static int MIN_TIMEOUT = 3; // TODO: Maybe better to switch interleaveio and timeout? private boolean debug; + private Report report; - public Plan(Language language, boolean debug) throws IOException { - this.language = language; + public Plan(boolean debug) throws IOException { this.debug = debug; if (debug) addScript("debug"); } + + public void setLanguage(Language language) { + this.language = language; + } + public void setReport(Report report) { + this.report = report; + } + + public Report getReport() { + return this.report; + } + + public void writeSolutionOutputs(Map filesToSave) { + for (Map.Entry entry : outputs.entrySet()) { + Path p = entry.getKey(); + if (p.getName(p.getNameCount() - 1).toString().equals("_errors")) + return; + } + for (Map.Entry entry : outputs.entrySet()) { + Path p = entry.getKey(); + if (p.getName(0).toString().startsWith("solution")) { + filesToSave.put(Path.of("_outputs").resolve(p), entry.getValue()); + } + } + } + + public void readSolutionOutputs(Map savedFiles) { + boolean removeOnly = false; + for (Map.Entry entry : savedFiles.entrySet()) { + Path p = entry.getKey(); + if (p.getName(p.getNameCount() - 1).toString().equals("_errors")) + removeOnly = true; + } + Set toRemove = new HashSet<>(); + for (Map.Entry entry : savedFiles.entrySet()) { + Path p = entry.getKey(); + if (p.getName(0).toString().equals("_outputs")) { + if (!removeOnly) outputs.put(p.subpath(1, p.getNameCount()), entry.getValue()); + toRemove.add(p); + } + } + savedFiles.keySet().removeAll(toRemove); + } + public String nextID(String prefix) { nextID++; return prefix + nextID; @@ -65,7 +111,7 @@ public boolean checkSolutionCompiled(String compileDir, Report report, Score sco String errorReport = getOutputString(compileDir, "_errors"); if (errorReport == null) return true; if (errorReport.trim().equals("")) - report.systemError("Compilation of solution ailed"); + report.systemError("Compilation of solution failed"); else report.systemError(errorReport); score.setInvalid(); @@ -120,7 +166,8 @@ public void compile(String compileDir, String sourceDirs, List sourceFiles allSourceFiles.addAll(sourceFiles); allSourceFiles.addAll(dependentSourceFiles); addScript("prepare " + compileDir + " use " + sourceDirs); - addScript("compile " + compileDir + " " + language.getLanguage() + " " + Util.join(allSourceFiles, " ")); + if (!outputs.containsKey(Paths.get(compileDir).resolve("_compile"))) + addScript("compile " + compileDir + " " + language.getLanguage() + " " + Util.join(allSourceFiles, " ")); } // TODO maxOutputLen @@ -133,10 +180,12 @@ public void run(String compileDir, String runDir, String runID, Path mainFile, S if (!compileDir.equals(runDir)) addScript("prepare " + runDir + " " + compileDir); addFile(Paths.get("in").resolve(runID), input == null ? "" : input); - addScript("run " + runDir + " " + runID + " " + Math.max(MIN_TIMEOUT, (timeout + 500) / 1000) + " " + maxOutputLen + " " + interleaveIO + " " + language.getLanguage() + " " + mainFile + (args == null ? "" : " " + args)); + if (!outputs.containsKey(Paths.get(runID).resolve("_run"))) + addScript("run " + runDir + " " + runID + " " + Math.max(MIN_TIMEOUT, (timeout + 500) / 1000) + " " + maxOutputLen + " " + interleaveIO + " " + language.getLanguage() + " " + mainFile + (args == null ? "" : " " + args)); } public void run(String compileDir, String runDir, Path mainFile, String args, String input, Collection outfiles, int timeout, int maxOutputLen, boolean interleaveIO) { + if (outputs.containsKey(Paths.get(runDir).resolve("_run"))) return; run(compileDir, runDir, mainFile, input, args, timeout, maxOutputLen, interleaveIO); if (outfiles.size() > 0) addScript("collect " + runDir + " " + Util.join(outfiles, " ")); @@ -179,7 +228,7 @@ private void executeLocally(String scriptCommand, Report report) responseZip = Paths.get(lines[n]); if (!Files.exists(responseZip)) throw new CodeCheckException("comrun failed.\n" + result); - outputs = Util.unzip(Files.readAllBytes(responseZip)); + outputs.putAll(Util.unzip(Files.readAllBytes(responseZip))); } finally { if (!debug) { Files.deleteIfExists(requestZip); @@ -195,7 +244,9 @@ private void executeRemotely(String remoteURL, Report report) while (retries > 0) { try { byte[] responseZip = Util.fileUpload(remoteURL, "job", "job.zip", requestZip); - outputs = Util.unzip(responseZip); + if (responseZip.length < 2 || !(responseZip[0] == 0x50 && responseZip[1] == 0x4b)) + throw new IOException("Remote result not a zip file"); + outputs.putAll(Util.unzip(responseZip)); if (debug) { Path temp = Files.createTempFile("codecheck-request", ".zip"); System.out.println("Remote request at " + temp); diff --git a/app/com/horstmann/codecheck/Problem.java b/app/com/horstmann/codecheck/Problem.java index dc908c55..1ee448d4 100644 --- a/app/com/horstmann/codecheck/Problem.java +++ b/app/com/horstmann/codecheck/Problem.java @@ -61,7 +61,7 @@ public static class DisplayData { public Map useFiles = new LinkedHashMap<>(); public String description; } - + private Map problemFiles; private Map useFiles = new Util.FileMap(); // the files (sources and inputs) from problemFiles that must be copied to the directory @@ -79,11 +79,15 @@ public static class DisplayData { private static final Pattern IMG_PATTERN = Pattern .compile("[<]\\s*[iI][mM][gG]\\s*[sS][rR][cC]\\s*[=]\\s*['\"]([^'\"]*)['\"][^>]*[>]"); + private static final Pattern LINK_START = Pattern + .compile("<\\s*[aA]\\s+[^>]*[hH][rR][eE][fF]\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>"); + private static final Pattern LINK_END = Pattern.compile("<\\s*/\\s*[aA]\\s*>"); public Problem(Map problemFiles) throws IOException { - this.problemFiles = problemFiles; language = Language.languageFor(problemFiles.keySet()); if (language == null) throw new CodeCheckException("Cannot find language from " + problemFiles.keySet()); + + this.problemFiles = problemFiles; annotations = new Annotations(language); String[] delims = language.pseudoCommentDelimiters(); start = delims[0]; @@ -135,9 +139,9 @@ else if (initial.equals("solution")) { } } inputFiles.put(inputPath, problemFiles.get(inputPath)); - for (String runargs : annotations.findKeys("ARGS")) + for (String runargs : annotations.findAll("ARGS")) for (String arg : runargs.split("\\s+")) - if (isTextFile(arg)) { + if (isTextFile(arg)) { // TODO: would also make sense to display used image files Path argPath = Paths.get(arg); if (useFiles.containsKey(argPath)) { inputFiles.put(argPath, useFiles.get(argPath)); @@ -201,6 +205,26 @@ private String readDescription(String descriptionFile) { if (start != -1) result.replace(0, start, ""); + // Check if links are relative or not, if relative links, change it to normal text + Matcher linkMatcherStart = LINK_START.matcher(result); + Matcher linkMatcherEnd = LINK_END.matcher(result); + int startLink = 0; + int endLink = 0; + while (linkMatcherStart.find(startLink) && linkMatcherEnd.find(startLink)) { + startLink = linkMatcherStart.start(); + endLink = linkMatcherEnd.end(); + String hrefLink = result.substring(linkMatcherStart.start(1), linkMatcherStart.end(1)).toLowerCase(); + if (!(hrefLink.startsWith("http://") || hrefLink.startsWith("https://"))) { + int startContent = linkMatcherStart.end(); + int endContent = linkMatcherEnd.start(); + String contentOfLink = result.substring(startContent, endContent); + result.replace(startLink, endLink, contentOfLink); + startLink += contentOfLink.length(); + } + else + startLink = endLink; + } + Matcher matcher = IMG_PATTERN.matcher(result); start = 0; while (matcher.find(start)) { @@ -284,13 +308,15 @@ private EditorState processHideShow(String contents) { boolean hasEdit = false; boolean hasShow = false; boolean hasTile = false; + boolean firstHide = false; for (int i = 0; i < lines.length && !hasTile && !hasEdit; i++) { - Annotations.Annotation ann = Annotations.parse(lines[i], start, end); - if (ann.key.equals("EDIT")) hasEdit = true; + Annotations.Annotation ann = Annotations.parse(lines[i], start, end); + if (i == 0 && (ann.key.equals("HIDE") || ann.key.equals("HIDDEN"))) firstHide = true; + else if (ann.key.equals("EDIT")) hasEdit = true; else if (ann.key.equals("SHOW")) hasShow = true; else if (ann.key.equals("TILE")) hasTile = true; } - if (lines.length == 0 || Annotations.parse(lines[0], start, end).key.equals("HIDE") && !hasShow && !hasEdit) { + if (lines.length == 0 || firstHide && !hasShow && !hasEdit) { EditorState state = new EditorState(); state.editors = new ArrayList(); // Empty list means file is hidden return state; @@ -420,17 +446,6 @@ private EditorState hideEditMode(String[] lines) { hiding = true; if (hiding) lines[i] = null; - if (ann.key.equals("SHOW")) { - hiding = false; - String showString = start + "SHOW"; - int n1 = lines[i].indexOf(showString); - int n2 = showString.length(); - int n3 = lines[i].lastIndexOf(end); - if (n1 + n2 < n3) - lines[i] = lines[i].substring(0, n1) + lines[i].substring(n1 + n2 + 1, n3); - else - lines[i] = null; - } } } // Emit final section @@ -677,6 +692,7 @@ private EditorState tileMode(String[] lines) { return state; } + // TODO: This could be more liberal--see HTMLReport for identifying text vs. binary private static boolean isTextFile(String p) { return p.toLowerCase().endsWith(".txt"); } private static boolean isTextFile(Path p) { return isTextFile(p.toString()); } @@ -721,7 +737,7 @@ public Problem.DisplayData getProblemData() { } public String getId() { - String problemId = annotations.findUniqueKey("ID"); + String problemId = annotations.findUnique("ID"); if (problemId == null) { // TODO: Move to Problem problemId = Util.removeExtension(solutionFiles.keySet().iterator().next().getFileName()); } diff --git a/app/com/horstmann/codecheck/PythonLanguage.java b/app/com/horstmann/codecheck/PythonLanguage.java index bfa7aba2..93673bcb 100644 --- a/app/com/horstmann/codecheck/PythonLanguage.java +++ b/app/com/horstmann/codecheck/PythonLanguage.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Map; import java.util.regex.Pattern; +import java.util.ArrayList; public class PythonLanguage implements Language { @@ -51,7 +52,7 @@ public boolean isMain(Path p, String contents) { @Override public Map writeTester(Path file, String contents, List calls, ResourceLoader resourceLoader) { String moduleName = moduleOf(file); - List lines = Util.lines(contents); + List lines = new ArrayList<>(); int i = 0; lines.add(i++, "from sys import argv"); lines.add(i++, "import " + moduleName); @@ -61,27 +62,9 @@ public Map writeTester(Path file, String contents, List[A-Za-z][A-Za-z0-9]*)\\s*=\\s*(?.+)"); + private static Pattern varPattern = Pattern.compile("(?[A-Za-z][A-Za-z0-9_]*)\\s*=\\s*(?.+)"); @Override public Pattern variableDeclPattern() { return varPattern; } private static Pattern errPattern = Pattern.compile("(?.*)[\"(](?[A-Za-z0-9_]+\\.py)\"?, line (?[0-9]+).*"); @Override public Pattern errorPattern() { return errPattern; } - public boolean isUnitTest(Path fileName) { return fileName.toString().matches(".*Test[0-9]*.py"); } + public boolean isUnitTest(Path fileName) { return fileName.toString().matches(".*(T|_t)est[0-9]*.py"); } private static final Pattern successPattern = Pattern.compile("Ran (?[0-9]+) tests? in [0-9.]+s\\s+OK"); private static final Pattern failurePattern = Pattern.compile("Ran (?[0-9]+) tests? in [0-9.]+s\\s+FAILED \\([^=]+=(?[0-9]+)\\)"); diff --git a/app/com/horstmann/codecheck/RacketLanguage.java b/app/com/horstmann/codecheck/RacketLanguage.java index aabb67fa..012a8fc5 100644 --- a/app/com/horstmann/codecheck/RacketLanguage.java +++ b/app/com/horstmann/codecheck/RacketLanguage.java @@ -46,12 +46,10 @@ public boolean isMain(Path p, String contents) { @Override public Map writeTester(Path file, String contents, List calls, ResourceLoader resourceLoader) { String moduleName = moduleOf(file); - // Copy source/module.rkt to target/module-solution.rkt StringBuilder out = new StringBuilder(); out.append("#lang racket\n"); - out.append("(require (prefix-in solution:: \"" + moduleName + "-solution.rkt\"))\n"); - out.append("(require (prefix-in student:: \"" + moduleName + ".rkt\"))\n"); + out.append("(require (prefix-in submission:: \"" + moduleName + ".rkt\"))\n"); out.append("(provide main)\n"); out.append("(define (main . args)\n"); out.append("(let ((arg (car args))) (cond\n"); @@ -61,29 +59,27 @@ public Map writeTester(Path file, String contents, List testerFiles = new HashMap<>(); testerFiles.put(Paths.get(moduleName + "CodeCheck.rkt"), out.toString()); - testerFiles.put(Paths.get(moduleName+ "-solution.rkt"), contents); return testerFiles; } - - private static String patternString = ".*\\S\\s+(?\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)\\s*=\\s*(?[^;]+);.*"; - private static Pattern pattern = Pattern.compile(patternString); - @Override public Pattern variableDeclPattern() { return pattern; } + // CAUTION: Because the rhs is likely to contain (), there must be a space + // before the final ), e.g. (define l '(1 2 3) ) + private static Pattern VARIABLE_DECL_PATTERN = Pattern.compile( + "\\(\\s*define\\s+(?[A-Za-z][A-Za-z0-9_]*)\\s+(?.+)\\s+\\)"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } + @Override public boolean isUnitTest(Path fileName) { - return fileName.toString().matches(".*Test[0-9]*.rkt"); + return fileName.toString().matches(".*(T|_t)est[0-9]*.rkt"); } private static final Pattern successPattern = Pattern.compile("All (?[0-9]+) tests passed"); diff --git a/app/com/horstmann/codecheck/Report.java b/app/com/horstmann/codecheck/Report.java index 387711f6..c88a4419 100644 --- a/app/com/horstmann/codecheck/Report.java +++ b/app/com/horstmann/codecheck/Report.java @@ -1,7 +1,6 @@ package com.horstmann.codecheck; import java.awt.image.BufferedImage; -import java.io.IOException; import java.nio.file.Path; import java.util.List; import java.util.Set; @@ -18,7 +17,7 @@ static class Match Report header(String section, String text); - Report run(String caption); + Report run(String caption, String mainclass, boolean hidden); Report output(CharSequence text); @@ -27,33 +26,43 @@ static class Match Report systemError(String message); Report systemError(Throwable t); + + default Report condition(boolean passed, boolean forbidden, Path path, String regex, String message) { + if (!passed) { + if (message == null) message = (forbidden ? "Found " : "Did not find ") + regex; + error(path + ": " + message); + } + return this; + } - Report image(String caption, BufferedImage image) throws IOException; - - Report image(BufferedImage image) throws IOException; - + Report image(String caption, String filename, BufferedImage image); + Report file(String file, String contents); + Report file(String file, byte[] contents, boolean hidden); Report args(String args); Report input(String input); Report add(Score score); - - Report save(Path dir, String out) throws IOException; Report pass(boolean b); Report compareTokens(String filename, List matches); Report output(List lines, Set matches, Set mismatches); - Report runTable(String[] functionNames, String[] argNames, String[][] args, String[] actual, String[] expected, boolean[] outcomes); - Report comment(String key, String value); + // TODO: record for RunTableRow + Report runTable(String[] functionNames, String[] argNames, String[][] args, String[] actual, String[] expected, boolean[] outcomes, boolean[] hidden, String mainclass); + Report comment(String key, String value); + default Report attribute(String key, Object value) { return this; } + Report footnote(String text); default void close() {} String getText(); default Report errors(List errors) { return this; } + + String extension(); } \ No newline at end of file diff --git a/app/com/horstmann/codecheck/RustLanguage.java b/app/com/horstmann/codecheck/RustLanguage.java new file mode 100644 index 00000000..02b2d4dc --- /dev/null +++ b/app/com/horstmann/codecheck/RustLanguage.java @@ -0,0 +1,58 @@ +package com.horstmann.codecheck; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +public class RustLanguage implements Language { + public String getExtension() { return "rs"; }; + + private static Pattern mainPattern = Pattern.compile("fn\\s+main\\s*\\([^)]*\\)\\s*(\\{\\s*)?"); + @Override public Pattern mainPattern() { return mainPattern; } + + private static Pattern VARIABLE_DECL_PATTERN =Pattern.compile("let\\s*(mut\\s*)?(?[A-Za-z][A-Za-z0-9_]*)\\s*(:\\s*(((i|u)(8|16|32|64|128|size))|(f32|f64|bool|char)|(\\[(((i|u)(8|16|32|64|128|size))|(f32|f64|bool|char))\\s*;\\s*[0-9]*\\s*\\]))\\s*)?=\\s*(?.+);\\s*"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } + + @Override + public boolean isUnitTest(Path fileName) { + return fileName.toString().matches(".*(T|_t)est[0-9]*.rs"); + } + + @Override + public Map writeTester(Path file, String contents, List calls, ResourceLoader resourceLoader) { + String moduleName = moduleOf(file); + List lines = new ArrayList<>(); + lines.add("use std::env;"); + lines.add("mod " + moduleName + ";"); + lines.add("fn main() {"); + lines.add(" let args: Vec = env::args().collect();"); + for (int k = 0; k < calls.size(); k++) { + Calls.Call call = calls.get(k); + lines.add(" if args[1] == \"" + (k + 1) + "\" {"); + lines.add(" let result = " + moduleName + "::" + call.name + "(" + call.args + ");"); + lines.add(" println!(\"{:?}\", result);"); + lines.add(" }"); + } + lines.add("}"); + Path p = pathOf(moduleName + "CodeCheck"); + Map testFiles = new HashMap<>(); + testFiles.put(p, Util.join(lines, "\n")); + return testFiles; + } + + private static final Pattern successPattern = Pattern.compile("running (?[0-9]+) tests?[\\s\\S]*test result: ok. [0-9]+ passed; (?[0-9]+) failed;"); + @Override public Pattern unitTestSuccessPattern() { return successPattern; } + private static final Pattern failurePattern = Pattern.compile("running (?[0-9]+) tests?[\\s\\S]*test result: FAILED. [0-9]+ passed; (?[0-9]+) failed;"); + @Override public Pattern unitTestFailurePattern() { return failurePattern; } + + // TODO: Change regex to include error message + private static Pattern ERROR_PATTERN = Pattern.compile("--> (?[^\\s\\S]+\\.rs):(?[0-9]+)"); + @Override public Pattern errorPattern() { return ERROR_PATTERN; } + + + + +} diff --git a/app/com/horstmann/codecheck/SMLLanguage.java b/app/com/horstmann/codecheck/SMLLanguage.java index 9f306d43..e9622e1d 100644 --- a/app/com/horstmann/codecheck/SMLLanguage.java +++ b/app/com/horstmann/codecheck/SMLLanguage.java @@ -40,50 +40,24 @@ public Map writeTester(Path file, String contents, List NONE ;\n"); - out.append("fun comp expr1 expr2 = let\n"); - out.append(" val actual = eval expr1\n"); - out.append(" val expected = eval expr2\n"); - out.append(" in\n"); - out.append(" (case (actual, expected) of\n"); - out.append(" (NONE, NONE) => \"exception\\nexception\\ntrue\\n\" |\n"); - out.append(" (NONE, SOME y) => \"exception\\n\" ^ (PolyML.makestring y) ^ \"\\nfalse\\n\" |\n"); - out.append(" (SOME x, NONE) => (PolyML.makestring x) ^ \"\\nexception\\nfalse\\n\" |\n"); - out.append(" (SOME x, SOME y) => (PolyML.makestring x) ^ \"\\n\" ^ (PolyML.makestring y) ^ \"\\n\" ^ (PolyML.makestring (x = y)) ^ \"\\n\")\n"); - out.append("end;\n"); - out.append("fun main() = print (case hd(CommandLine.arguments()) of \n"); + out.append("fun main() = print (PolyML.makestring (case hd(CommandLine.arguments()) of \n"); for (int k = 0; k < calls.size(); k++) { Calls.Call call = calls.get(k); if (k < calls.size() - 1) - out.append(" \"" + (k + 1) + "\" => comp (fn () => (Solution." + - call.name + " " + call.args + ")) (fn () => (" + - call.name + " " + call.args + ")) |\n"); + out.append(" \"" + (k + 1) + "\" => (" + + call.name + " " + call.args + ") |\n"); else - out.append(" _ => comp (fn () => (Solution." + - call.name + " " + call.args + ")) (fn () => (" + - call.name + " " + call.args + ")));\n"); + out.append(" _ => (" + + call.name + " " + call.args + ")) handle exn => \"exception\");\n"); } Map result = new HashMap<>(); result.put(testFile, out.toString()); return result; } - private static String variablePatternString = "\\s*val\\s+(?\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)\\s*=\\s*(?.+)"; - private static Pattern variablePattern = Pattern.compile(variablePatternString); - - /* - * (non-Javadoc) - * - * @see com.horstmann.codecheck.Language#variablePattern() - */ - @Override - public Pattern variableDeclPattern() { - return variablePattern; - } + private static Pattern VARIABLE_DECL_PATTERN = Pattern.compile( + "val\\s+(?\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)\\s*=\\s*(?[^;]+);?"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } private static Pattern ERROR_PATTERN = Pattern.compile("(?[^/]+\\.sml):(?[0-9]+):(?[0-9]+): (?.+)"); @Override public Pattern errorPattern() { return ERROR_PATTERN; } diff --git a/app/com/horstmann/codecheck/ScalaLanguage.java b/app/com/horstmann/codecheck/ScalaLanguage.java index d113e654..9e7e4c70 100644 --- a/app/com/horstmann/codecheck/ScalaLanguage.java +++ b/app/com/horstmann/codecheck/ScalaLanguage.java @@ -3,6 +3,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.HashMap; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.regex.Matcher; @@ -28,36 +29,19 @@ public boolean isMain(Path p, String contents) { @Override public Map writeTester(Path file, String contents, List calls, ResourceLoader resourceLoader) { - List lines = Util.lines(contents); - - // Find class name - int i = 0; - while (i < lines.size() && !lines.get(i).contains("//CALL")) i++; - if (i == lines.size()) - throw new CodeCheckException("Can't find object in " + file); - while (i >= 0 && !lines.get(i).trim().startsWith("object")) i--; - if (i < 0) - throw new CodeCheckException("Can't find object in " + file); - String[] tokens = lines.get(i).split("\\P{javaJavaIdentifierPart}+"); - int j = 0; - if (tokens[0].length() == 0) j++; - if (j + 1 >= tokens.length) throw new CodeCheckException("Can't find object name in " + file); - String objectName = tokens[j + 1]; - lines.add(0, "object " + objectName + "CodeCheck extends App {"); - lines.add(1, "object Solution {"); - lines.add("}}"); - i = 0; + // Requirement: File name must equal to object containing called method + String objectName = moduleOf(file); + List lines = new ArrayList<>(); + lines.add("object " + objectName + "CodeCheck extends App {"); for (int k = 0; k < calls.size(); k++) { Calls.Call call = calls.get(k); String submissionFun = objectName + "." + call.name; - String solutionFun = "Solution." + submissionFun; - lines.add(++i, "if (args(0) == \"" + (k + 1) + "\") {"); - lines.add(++i, "val actual = " + submissionFun + "(" + call.args + ")"); - lines.add(++i, "val expected = " + solutionFun + "(" + call.args + ")"); - lines.add(++i, "println(runtime.ScalaRunTime.stringOf(expected))"); - lines.add(++i, "println(runtime.ScalaRunTime.stringOf(actual))"); - lines.add(++i, "println(actual == expected) }"); - } + lines.add("if (args(0) == \"" + (k + 1) + "\") {"); + lines.add(" val result = " + submissionFun + "(" + call.args + ")"); + lines.add(" println(runtime.ScalaRunTime.stringOf(result))"); + lines.add("}"); + } + lines.add("}"); Path p = Paths.get(objectName + "CodeCheck.scala"); Map testModules = new HashMap<>(); testModules.put(p, Util.join(lines, "\n")); @@ -73,9 +57,7 @@ public Map writeTester(Path file, String contents, List\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)(\\s*:[^=]+\\s*)?\\s*=\\s*(?[^;]+);.*"); - - @Override - public Pattern variableDeclPattern() { return variablePattern; } + private static Pattern VARIABLE_DECL_PATTERN = Pattern.compile( + "(val|var)\\s+(?\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)(\\s*:[^=]+\\s*)?\\s*=\\s*(?[^;]+);"); + @Override public Pattern variableDeclPattern() { return VARIABLE_DECL_PATTERN; } } diff --git a/app/com/horstmann/codecheck/SetupReport.java b/app/com/horstmann/codecheck/SetupReport.java new file mode 100644 index 00000000..2415f023 --- /dev/null +++ b/app/com/horstmann/codecheck/SetupReport.java @@ -0,0 +1,107 @@ +package com.horstmann.codecheck; + +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +public class SetupReport extends JSONReport { + private Problem problem; + private Map attributes = new HashMap<>(); + private List conditions = new ArrayList<>(); + + static class Condition { + public boolean forbidden; + public String path; + public String regex; + public String message; + } + + public SetupReport(String title) { + super(title); + } + + public void setProblem(Problem problem) { + this.problem = problem; + } + + @Override public JSONReport attribute(String key, Object value) { + attributes.put(key, value); + return this; + } + + @Override public JSONReport condition(boolean passed, boolean forbidden, Path path, String regex, String message) { + Condition c = new Condition(); + c.forbidden = forbidden; + c.path = path.toString(); + c.regex = regex; + c.message = message; + conditions.add(c); + return this; + } + + @Override public String getText() { + for (Section section : data.sections) { + for (Run run : section.runs) { + if (run.output == null || run.output == "") { + StringBuilder output = new StringBuilder(); + for (Match match : run.matchedOutput) { + output.append(match.actual); + output.append("\n"); + } + run.matchedOutput = null; + run.output = output.toString(); + } + run.passed = null; + run.html = null; + } + } + data.metaData.clear(); + data.score = null; + ObjectNode dataNode = Util.toJson(data); + if (problem != null) { + Problem.DisplayData displayData = problem.getProblemData(); + ObjectNode problemNode = Util.toJson(displayData); + Iterator> fields = problemNode.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + dataNode.set(entry.getKey(), entry.getValue()); + } + Map useFiles = problem.getUseFiles(); + ObjectNode hiddenFiles = JsonNodeFactory.instance.objectNode(); + for (Map.Entry entry: useFiles.entrySet() ) { + String name = entry.getKey().toString(); + byte[] contents = entry.getValue(); + if (!displayData.useFiles.containsKey(name)) { + try { + hiddenFiles.put(name, StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(contents)).toString()); + } catch (CharacterCodingException e) { + ObjectNode node = JsonNodeFactory.instance.objectNode(); + node.put("data", Base64.getEncoder().encodeToString(contents)); + hiddenFiles.set(name, node); + } + } + } + if (hiddenFiles.size() > 0) { + dataNode.set("hiddenFiles", hiddenFiles); + } + if (!attributes.isEmpty()) { + dataNode.set("attributes", Util.toJson(attributes)); + } + if (!conditions.isEmpty()) { + dataNode.set("conditions", Util.toJson(conditions)); + } + } + return dataNode.toString(); + } +} diff --git a/app/com/horstmann/codecheck/Substitution.java b/app/com/horstmann/codecheck/Substitution.java index a7edc984..433ea662 100644 --- a/app/com/horstmann/codecheck/Substitution.java +++ b/app/com/horstmann/codecheck/Substitution.java @@ -3,9 +3,11 @@ import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -18,20 +20,22 @@ public class Substitution { public Substitution(Language language) { this.language = language; } - + public void addVariable(Path file, String decl, String args) { if (this.file == null) this.file = file; else if (!this.file.equals(file)) throw new CodeCheckException("SUB in " + this.file + " and " + file); Pattern pattern = language.variableDeclPattern(); - Matcher matcher = pattern.matcher(decl); + Matcher matcher = pattern.matcher(decl.trim()); if (matcher.matches()) { String name = matcher.group("name").trim(); ArrayList values = new ArrayList<>(); + if (subs.containsKey(name)) + throw new CodeCheckException("More than one SUB in " + file + " for " + name); subs.put(name, values); values.add(matcher.group("rhs")); - for (String v : args.split(language.substitutionSeparator())) + for (String v : language.substitutionSeparator().split(args)) if (v.trim().length() > 0) values.add(v); if (size == 0) @@ -60,19 +64,38 @@ public List values(int i) { for (String n : subs.keySet()) r.add(subs.get(n).get(i)); return r; } - + + public static String removeComment(String line, String start, String end) { + int i = line.indexOf(start); + if (i < 0) return line; + if (end.isBlank()) return line.substring(0, i); + int j = line.lastIndexOf(end); + if (j < 0) return line; + return line.substring(0, i) + line.substring(j + end.length()); + } + public String substitute(String contents, int n) throws IOException { Pattern pattern = language.variableDeclPattern(); List lines = Util.lines(contents); StringBuilder out = new StringBuilder(); - for (String line : lines) { - Matcher matcher = pattern.matcher(line); + String[] delims = language.pseudoCommentDelimiters(); + String start = delims[0]; + String end = delims[1]; + Set alreadyUsed = new HashSet(); + for (String l : lines) { + String line = removeComment(l, start, end); + int i = 0; + while (i < line.length() && Character.isWhitespace(line.charAt(i))) i++; + int j = line.length() - 1; + while (j >= i && Character.isWhitespace(line.charAt(j))) j--; + Matcher matcher = pattern.matcher(line.substring(i, j + 1)); if (matcher.matches()) { String name = matcher.group("name"); - if (subs.containsKey(name)) { - out.append(line.substring(0, matcher.start("rhs"))); + if (subs.containsKey(name) && !alreadyUsed.contains(name)) { + alreadyUsed.add(name); + out.append(line.substring(0, i + matcher.start("rhs"))); out.append(subs.get(name).get(n)); - out.append(line.substring(matcher.end("rhs"))); + out.append(line.substring(i + matcher.end("rhs"))); out.append("\n"); } else { out.append(line); diff --git a/app/com/horstmann/codecheck/TextReport.java b/app/com/horstmann/codecheck/TextReport.java index c215e933..05302672 100644 --- a/app/com/horstmann/codecheck/TextReport.java +++ b/app/com/horstmann/codecheck/TextReport.java @@ -1,12 +1,10 @@ package com.horstmann.codecheck; import java.awt.image.BufferedImage; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.MissingResourceException; import java.util.ResourceBundle; import java.util.Set; @@ -15,21 +13,28 @@ public class TextReport implements Report { private int sections; private String section; private List footnotes = new ArrayList<>(); + private boolean hidden; public TextReport(String title) { + // TODO This version is only built by the client-side Ant script builder = new StringBuilder(); - builder.append("codecheck version "); - builder.append(ResourceBundle.getBundle( - "com.horstmann.codecheck.codecheck").getString("version")); - builder.append(" "); - builder.append(" started "); - builder.append(new Date()); - builder.append("\n\n"); + try { + builder.append("codecheck version "); + builder.append(ResourceBundle.getBundle( + "com.horstmann.codecheck.codecheck").getString("version")); + builder.append(" "); + builder.append(" started "); + builder.append(new Date()); + builder.append("\n\n"); + } catch (MissingResourceException e) { + builder = new StringBuilder(); + } } private TextReport add(CharSequence s) { + if (s == null) return this; builder.append(s); - if (s != null && s.length() > 0 && s.charAt(s.length() - 1) != '\n') + if (s.length() > 0 && s.charAt(s.length() - 1) != '\n') builder.append("\n"); return this; } @@ -42,6 +47,7 @@ private TextReport add(CharSequence s) { @Override public TextReport header(String section, String text) { this.section = section; + hidden = false; if ("studentFiles".equals(section) || "providedFiles".equals(section)) return this; if (sections > 0) @@ -70,20 +76,20 @@ private TextReport caption(String text) { */ @Override public TextReport output(CharSequence text) { - output(null, text); + if (hidden) add("[Hidden]"); else add(text); return this; } @Override public TextReport args(String args) { if (args != null && args.trim().length() > 0) - output("Command line arguments: " + args); + add("Command line arguments: " + args); return this; } @Override public TextReport input(String input) { - output("Input", input); + addCaptioned("Input", hidden ? "[Hidden]" : input); return this; } @@ -93,7 +99,7 @@ public Report footnote(String text) { return this; } - private TextReport output(String captionText, CharSequence text) { + private TextReport addCaptioned(String captionText, CharSequence text) { if (text == null || text.equals("")) return this; caption(captionText); @@ -104,22 +110,26 @@ private TextReport output(String captionText, CharSequence text) { @Override public TextReport output(List lines, Set matches, Set mismatches) { - for (int i = 0; i < lines.size(); i++) { - String line = lines.get(i); - if (matches.contains(i)) - builder.append("+ "); - else if (mismatches.contains(i)) - builder.append("- "); - else - builder.append(" "); - add(line); - } + if (hidden) { + add("[Hidden]"); + } else { + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (matches.contains(i)) + builder.append("+ "); + else if (mismatches.contains(i)) + builder.append("- "); + else + builder.append(" "); + add(line); + } + } return this; } private TextReport error(String captionText, String message) { caption(captionText); - output(message); + add(message); return this; } @@ -141,8 +151,7 @@ public TextReport error(String message) { */ @Override public TextReport systemError(String message) { - add("System Error:"); - output(message); + error("System Error", message); return this; } @@ -157,36 +166,27 @@ public TextReport systemError(Throwable t) { * @see com.horstmann.codecheck.Report#image(java.lang.String, byte[]) */ @Override - public TextReport image(String captionText, BufferedImage img) { - image(img); - return this; - } - - @Override - public TextReport image(BufferedImage image) { - /* - try { - imageCount++; - Path out = dir.resolve("report" + imageCount + ".png"); - ImageIO.write(image, "PNG", out.toFile()); - } catch (IOException ex) { - error("No image"); - } - */ + public TextReport image(String captionText, String filename, BufferedImage img) { return this; } @Override public TextReport file(String file, String contents) { - if ("studentFiles".equals(section) || "providedFiles".equals(section)) return this; + if ("studentFiles".equals(section)) return this; caption(file); - output(contents); // TODO: Line numbers? + add(contents); // TODO: Line numbers? return this; } + + public TextReport file(String fileName, byte[] contents, boolean hidden) { + // Only happens in providedFiles section + return this; + } @Override - public TextReport run(String caption) { + public TextReport run(String caption, String mainclass, boolean hidden) { caption(caption); + this.hidden = hidden; return this; } @@ -205,20 +205,11 @@ public TextReport add(Score score) { return this; } - /* - * (non-Javadoc) - * - * @see com.horstmann.codecheck.Report#save(java.nio.file.Path) - */ @Override - public TextReport save(Path dir, String out) throws IOException { - Path outPath = dir.resolve(out + ".txt"); - Files.write(outPath, builder.toString().getBytes()); - return this; - } + public String getText() { return builder.toString(); } @Override - public String getText() { return builder.toString(); } + public String extension() { return "txt"; } @Override public TextReport pass(boolean b) { @@ -288,7 +279,7 @@ public TextReport compareTokens(String filename, List matchData) { @Override public TextReport runTable(String[] methodNames, String[] argNames, String[][] args, String[] actual, String[] expected, - boolean[] outcomes) { + boolean[] outcomes, boolean[] hidden, String mainclass) { int cols0 = 0; if (methodNames != null) { @@ -315,19 +306,27 @@ public TextReport runTable(String[] methodNames, String[] argNames, add("------"); for (int i = 0; i < args.length; i++) { - if (methodNames != null) - pad(methodNames[i], cols0); - for (int j = 0; j < n; j++) - pad(args[i][j], cols[j]); - pad(actual[i], cols[n]); - pad(expected[i], cols[n + 1]); + if (hidden != null && hidden[i]) { + pad("?", cols0); + for (int j = 0; j < n; j++) + pad("?", cols[j]); + pad("?", cols[n]); + pad("?", cols[n + 1]); + } else { + if (methodNames != null) + pad(methodNames[i], cols0); + for (int j = 0; j < n; j++) + pad(args[i][j], cols[j]); + pad(actual[i], cols[n]); + pad(expected[i], cols[n + 1]); + } pass(outcomes[i]); } return this; } public TextReport comment(String key, String value) { - if (builder.charAt(builder.length() - 1) != '\n') + if (builder.length() > 0 && builder.charAt(builder.length() - 1) != '\n') builder.append('\n'); builder.append("# "); builder.append(key); diff --git a/app/com/horstmann/codecheck/Util.java b/app/com/horstmann/codecheck/Util.java index 6d34f8f0..ff5bda12 100644 --- a/app/com/horstmann/codecheck/Util.java +++ b/app/com/horstmann/codecheck/Util.java @@ -45,6 +45,12 @@ import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + public class Util { private static Random generator = new Random(); @@ -105,7 +111,10 @@ public static Path tail(Path p) { } public static String extension(Path path) { - String name = path.toString(); + return extension(path.toString()); + } + + public static String extension(String name) { int n = name.lastIndexOf('.'); if (n == -1) return ""; @@ -391,7 +400,7 @@ public FileVisitResult visitFile(Path file, public static byte[] fileUpload(String urlString, String fieldName, String fileName, byte[] bytes) throws IOException { final int TIMEOUT = 90000; // 90 seconds String boundary = "===" + createPrivateUID() + "==="; - URL url = new URL(urlString); + URL url = URI.create(urlString).toURL(); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setConnectTimeout(TIMEOUT); conn.setReadTimeout(TIMEOUT); @@ -441,7 +450,7 @@ public static byte[] fileUpload(String urlString, String fieldName, String fileN public static String httpPost(String urlString, String content, String contentType) { StringBuilder result = new StringBuilder(); try { - URL url = new URL(urlString); + URL url = URI.create(urlString).toURL(); HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setRequestProperty("Content-Type", contentType); connection.setDoOutput(true); @@ -521,10 +530,11 @@ public static Map getParams(String url) return params; } + // TODO: What about redirects? public static boolean exists(String url) { boolean result = false; try { - HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection(); try { conn.connect(); result = conn.getHeaderField(null).contains("200"); @@ -538,6 +548,7 @@ public static boolean exists(String url) { // UIDs + // TODO Consider using https://github.com/scru128/spec public static String createPrivateUID() { return new BigInteger(128, generator).toString(36).toUpperCase(); } @@ -554,21 +565,62 @@ private static String datePrefix() { public static String createPronouncableUID() { StringBuilder result = new StringBuilder(); - int len = 16; - int b = Util.generator.nextInt(2); + int len = 4; for (int i = 0; i < len; i++) { - String s = i % 2 == b ? Util.consonants : vowels; - int n = Util.generator.nextInt(s.length()); - result.append(s.charAt(n)); - if (i % 4 == 3 && i < len - 1) { - result.append('-'); - b = Util.generator.nextInt(2); - } + if (i > 0) result.append("-"); + result.append(generatePronounceableWord()); } return result.toString(); } + + // generates a non-bad four-letter pronounceable word + // of the form vcvc or cvcv (c = consonant, v = vowel) + private static StringBuilder generatePronounceableWord() { + StringBuilder word; + int len = 4; + int b = Util.generator.nextInt(2); + do { + word = new StringBuilder(); + for (int i = 0; i < len; i++) { + String s = i % 2 == b ? Util.consonants : vowels; + int n = Util.generator.nextInt(s.length()); + word.append(s.charAt(n)); + } + } while (isBadWord(word.toString())); // generate a word until we get a non bad word + + return word; + } + private static boolean isBadWord(String word){ + String[] filteredOutWords = {"anal", "anus", "anil", "anes", "anis", "babe", "bozo", "coky", "dick", "dike", "dyke", "homo", "lube", "nude", "oral", "rape", "sexy", "titi", "wily"}; + return Arrays.asList(filteredOutWords).contains(word); + } public static boolean isPronouncableUID(String s) { return s.matches("(([aeiouy][bcdfghjklmnpqrstvwxz]){2}|([bcdfghjklmnpqrstvwxz][aeiouy]){2})(-(([aeiouy][bcdfghjklmnpqrstvwxz]){2}|([bcdfghjklmnpqrstvwxz][aeiouy]){2})){3}"); } + + private static ObjectMapper mapper = new ObjectMapper(); + static { + mapper.setSerializationInclusion(Include.NON_DEFAULT); + } + + public static ObjectNode toJson(Object obj) { + return (ObjectNode) mapper.convertValue(obj, JsonNode.class); + } + + public static String toJsonString(Object obj) { + try { + return mapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + return null; + } + } + + public static ObjectNode fromJsonString(String jsonString) throws JsonProcessingException, IOException { + return (ObjectNode) mapper.readTree(jsonString); + } + + public static ObjectNode fromJsonString(byte[] jsonStringBytes) throws JsonProcessingException, IOException { + return (ObjectNode) mapper.readTree(jsonStringBytes); + } } diff --git a/app/controllers/Application.java b/app/controllers/Application.java index 82eccec8..6c35c741 100644 --- a/app/controllers/Application.java +++ b/app/controllers/Application.java @@ -13,15 +13,16 @@ public class Application extends Controller { public Result health(Http.Request request) { try { String df = Util.runProcess("/bin/df /", 1000); - String mp = Util.runProcess("/usr/bin/mpstat 2 1", 3000); Matcher matcher = dfPattern.matcher(df); if (matcher.matches()) { String percent = matcher.group("percent"); if (Integer.parseInt(percent) > 95) return internalServerError("disk " + percent + "% full"); + Runtime rt = Runtime.getRuntime(); + double mem = rt.freeMemory() * 100.0 / rt.totalMemory(); // TODO: Analyze output // http://stackoverflow.com/questions/9229333/how-to-get-overall-cpu-usage-e-g-57-on-linux - return ok("CodeCheck\n" + df + "\n" + mp); + return ok("CodeCheck\n" + df + "\n" + mem + "% JVM memory free"); } else return ok("df output doesn't match pattern: " + df); } catch (Throwable ex) { diff --git a/app/controllers/Assignment.java b/app/controllers/Assignment.java index d1c1f4e9..7d9006d6 100644 --- a/app/controllers/Assignment.java +++ b/app/controllers/Assignment.java @@ -1,210 +1,38 @@ package controllers; -/* - An assignment is made up of problems. A problem is provided in a URL - that is displayed in an iframe. (In the future, maybe friendly problems - could coexist on a page or shared iframe for efficiency.) An assignment - weighs its problems. - - The "problem key" is normally the problem URL. However, for interactive or CodeCheck - problems in the textbook repo, it is the qid of the single question in the problem. - - CodeCheckWork is a map from problem keys to scores and states. It only stores the most recent version. - CodeCheckSubmissions is an append-only log of all submissions of a single problem. - - Tables: - - CodeCheckAssignment - assignmentID [primary key] - deadline (an ISO 8601 string like "2020-12-01T23:59:59Z") - editKey // LTI: tool consumer ID + user ID - problems - array of // One per group - array of { URL, qid?, weight } // qid for book repo - - CodeCheckLTIResources (Legacy) - resourceID [primary key] // LTI tool consumer ID + course ID + resource ID - assignmentID - - CodeCheckWork - assignmentID [partition key] // non-LTI: courseID? + assignmentID, LTI: toolConsumerID/courseID + assignment ID, Legacy tool consumer ID/course ID/resource ID - workID [sort key] // non-LTI: ccid/editKey, LTI: userID - problems - map from URL/qids to { state, score } - submittedAt - tab - - CodeCheckSubmissions - submissionID [partition key] // non-LTI: courseID? + assignmentID + problemKey + ccid/editKey , LTI: toolConsumerID/courseID + assignmentID + problemID + userID - // either way, that's resource ID + workID + problem key - submittedAt [sort key] - state: as string, not JSON - score - - with global secondary index - problemID - submitterID - - CodeCheckLTICredentials - oauth_consumer_key [primary key] - shared_secret - - - Assignment parsing format: - - Groups separated by 3 or more - - Each line: - urlOrQid (weight%)? title - - Cookies - ccid (student only) - cckey (student only) - PLAY_SESSION -*/ - import java.io.IOException; -import java.net.URLEncoder; +import java.lang.System.Logger; import java.security.GeneralSecurityException; import java.security.NoSuchAlgorithmException; -import java.time.Duration; -import java.time.Instant; -import java.time.format.DateTimeParseException; -import java.util.Map; import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import javax.inject.Inject; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; - -import models.S3Connection; import com.horstmann.codecheck.Util; -import play.Logger; + import play.mvc.Controller; import play.mvc.Http; import play.mvc.Result; +import services.ServiceException; public class Assignment extends Controller { - @Inject private S3Connection s3conn; - private static Logger.ALogger logger = Logger.of("com.horstmann.codecheck"); + @Inject private services.Assignment assignmentService; + // TODO Do we need to log, or does internalServerError do it already? + private static Logger logger = System.getLogger("com.horstmann.codecheck"); - public static ArrayNode parseAssignment(String assignment) { - if (assignment == null || assignment.trim().isEmpty()) - throw new IllegalArgumentException("No assignments"); - ArrayNode groupsNode = JsonNodeFactory.instance.arrayNode(); - Pattern problemPattern = Pattern.compile("\\s*(\\S+)(\\s+[0-9.]+%)?(.*)"); - String[] groups = assignment.split("\\s+-{3,}\\s+"); - for (int problemGroup = 0; problemGroup < groups.length; problemGroup++) { - String[] lines = groups[problemGroup].split("\\n+"); - if (lines.length == 0) throw new IllegalArgumentException("No problems given"); - ArrayNode group = JsonNodeFactory.instance.arrayNode(); - for (int i = 0; i < lines.length; i++) { - ObjectNode problem = JsonNodeFactory.instance.objectNode(); - Matcher matcher = problemPattern.matcher(lines[i]); - if (!matcher.matches()) - throw new IllegalArgumentException("Bad input " + lines[i]); - String problemDescriptor = matcher.group(1); // URL or qid - String problemURL; - String qid = null; - boolean checked = false; - if (problemDescriptor.startsWith("https")) problemURL = problemDescriptor; - else if (problemDescriptor.startsWith("http")) problemURL = "https" + problemDescriptor.substring(4); - else if (problemDescriptor.matches("[a-zA-Z0-9_]+(-[a-zA-Z0-9_]+)*")) { - qid = problemDescriptor; - problemURL = "https://www.interactivities.ws/" + problemDescriptor + ".xhtml"; - if (com.horstmann.codecheck.Util.exists(problemURL)) - checked = true; - else - problemURL = "https://codecheck.it/files?repo=wiley&problem=" + problemDescriptor; - } - else throw new IllegalArgumentException("Bad problem: " + problemDescriptor); - if (!checked && !com.horstmann.codecheck.Util.exists(problemURL)) - throw new IllegalArgumentException("Cannot find " + problemDescriptor); - problem.put("URL", problemURL); - if (qid != null) problem.put("qid", qid); - - String weight = matcher.group(2); - if (weight == null) weight = "100"; - else weight = weight.trim().replace("%", ""); - problem.put("weight", Double.parseDouble(weight) / 100); - - String title = matcher.group(3); - if (title != null) { - title = title.trim(); - if (!title.isEmpty()) - problem.put("title", title); - } - group.add(problem); - } - groupsNode.add(group); + public Result edit(Http.Request request, String assignmentID, String editKey) throws IOException { + try { + String result = assignmentService.edit(assignmentID, editKey); + return ok(result).as("text/html"); } - return groupsNode; - } - - private static boolean isProblemKeyFor(String key, ObjectNode problem) { - // Textbook repo - if (problem.has("qid")) return problem.get("qid").asText().equals(key); - String problemURL = problem.get("URL").asText(); - // Some legacy CodeCheck questions have butchered keys such as 0101407088y6iesgt3rs6k7h0w45haxajn - return problemURL.endsWith(key); - } - - public static double score(ObjectNode assignment, ObjectNode work) { - ArrayNode groups = (ArrayNode) assignment.get("problems"); - String workID = work.get("workID").asText(); - ArrayNode problems = (ArrayNode) groups.get(workID.hashCode() % groups.size()); - ObjectNode submissions = (ObjectNode) work.get("problems"); - double result = 0; - double sum = 0; - for (JsonNode p : problems) { - ObjectNode problem = (ObjectNode) p; - double weight = problem.get("weight").asDouble(); - sum += weight; - for (String key : com.horstmann.codecheck.Util.iterable(submissions.fieldNames())) { - if (isProblemKeyFor(key, problem)) { - ObjectNode submission = (ObjectNode) submissions.get(key); - result += weight * submission.get("score").asDouble(); - } - } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } + catch (Exception ex) { + logger.log(Logger.Level.ERROR, Util.getStackTrace(ex)); + return internalServerError(Util.getStackTrace(ex)); } - return sum == 0 ? 0 : result / sum; - } - - private static boolean editKeyValid(String suppliedEditKey, ObjectNode assignmentNode) { - String storedEditKey = assignmentNode.get("editKey").asText(); - return suppliedEditKey.equals(storedEditKey) && !suppliedEditKey.contains("/"); - // Otherwise it's an LTI edit key (tool consumer ID + user ID) - } - - /* - * assignmentID == null: new assignment - * assignmentID != null, editKey != null: edit assignment - * assignmentID != null, editKey == null: clone assignment - */ - public Result edit(Http.Request request, String assignmentID, String editKey) throws IOException { - ObjectNode assignmentNode; - if (assignmentID == null) { - assignmentNode = JsonNodeFactory.instance.objectNode(); - } else { - assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID); - if (assignmentNode == null) return badRequest("Assignment not found"); - - if (editKey == null) { // Clone - assignmentNode.remove("editKey"); - assignmentNode.remove("assignmentID"); - } - else { // Edit existing assignment - if (!editKeyValid(editKey, assignmentNode)) - // In the latter case, it is an LTI toolConsumerID + userID - return badRequest("editKey " + editKey + " does not match"); - } - } - assignmentNode.put("saveURL", "/saveAssignment"); - return ok(views.html.editAssignment.render(assignmentNode.toString(), true)); } /* @@ -218,113 +46,64 @@ public Result edit(Http.Request request, String assignmentID, String editKey) th public Result work(Http.Request request, String assignmentID, String ccid, String editKey, boolean isStudent) throws IOException, GeneralSecurityException { - String prefix = models.Util.prefix(request); - String workID = ""; - boolean editKeySaved = true; - - ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID); - if (assignmentNode == null) return badRequest("Assignment not found"); - - assignmentNode.put("isStudent", isStudent); - if (isStudent) { - if (ccid == null) { - Optional ccidCookie = request.getCookie("ccid"); - if (ccidCookie.isPresent()) { - ccid = ccidCookie.get().value(); - Optional editKeyCookie = request.getCookie("cckey"); - if (editKeyCookie.isPresent()) - editKey = editKeyCookie.get().value(); - else { // This shouldn't happen, but if it does, clear ID - ccid = com.horstmann.codecheck.Util.createPronouncableUID(); - editKey = Util.createPrivateUID(); - editKeySaved = false; - } - } else { // First time on this browser - ccid = com.horstmann.codecheck.Util.createPronouncableUID(); - editKey = Util.createPrivateUID(); - editKeySaved = false; - } - } else if (editKey == null) { // Clear ID request - ccid = com.horstmann.codecheck.Util.createPronouncableUID(); - editKey = Util.createPrivateUID(); - editKeySaved = false; - } - assignmentNode.put("clearIDURL", "/assignment/" + assignmentID + "/" + ccid); - workID = ccid + "/" + editKey; - } else { // Instructor - if (ccid == null && editKey != null && !editKeyValid(editKey, assignmentNode)) - throw new IllegalArgumentException("Edit key does not match"); - if (ccid != null && editKey != null) // Instructor viewing student submission - workID = ccid + "/" + editKey; + try { + String prefix = controllers.Util.prefix(request); + + if (isStudent) { + boolean editKeySaved = true; + if (ccid == null) { + Optional ccidCookie = request.getCookie("ccid"); + if (ccidCookie.isPresent()) { + ccid = ccidCookie.get().value(); + Optional editKeyCookie = request.getCookie("cckey"); + if (editKeyCookie.isPresent()) + editKey = editKeyCookie.get().value(); + else { // This shouldn't happen, but if it does, clear ID + ccid = Util.createPronouncableUID(); + editKey = Util.createPrivateUID(); + editKeySaved = false; + } + } else { // First time on this browser + ccid = Util.createPronouncableUID(); + editKey = Util.createPrivateUID(); + editKeySaved = false; + } + } else if (editKey == null) { // Clear ID request + ccid = Util.createPronouncableUID(); + editKey = Util.createPrivateUID(); + editKeySaved = false; + } + Http.Cookie newCookie1 = controllers.Util.buildCookie("ccid", ccid); + Http.Cookie newCookie2 = controllers.Util.buildCookie("cckey", editKey); + String result = assignmentService.work(prefix, assignmentID, ccid, editKey, true /* studet */, editKeySaved); + return ok(result).withCookies(newCookie1, newCookie2).as("text/html"); + } else { // Instructor + String result = assignmentService.work(prefix, assignmentID, ccid, editKey, false /* student */, false /* editKeySaved */); + return ok(result).as("text/html"); + } } - assignmentNode.remove("editKey"); - ArrayNode groups = (ArrayNode) assignmentNode.get("problems"); - assignmentNode.set("problems", groups.get(Math.abs(workID.hashCode()) % groups.size())); - - String work = null; - if (!workID.equals("")) - work = s3conn.readJsonStringFromDynamoDB("CodeCheckWork", "assignmentID", assignmentID, "workID", workID); - if (work == null) - work = "{ assignmentID: \"" + assignmentID + "\", workID: \"" - + workID + "\", problems: {} }"; - - String lti = "undefined"; - if (isStudent) { - String returnToWorkURL = prefix + "/private/resume/" + assignmentID + "/" + ccid + "/" + editKey; - assignmentNode.put("returnToWorkURL", returnToWorkURL); - assignmentNode.put("editKeySaved", editKeySaved); - assignmentNode.put("sentAt", Instant.now().toString()); - Http.Cookie newCookie1 = Http.Cookie.builder("ccid", ccid).withPath("/").withMaxAge(Duration.ofDays(180)).build(); - Http.Cookie newCookie2 = Http.Cookie.builder("cckey", editKey).withPath("/").withMaxAge(Duration.ofDays(180)).build(); - return ok(views.html.workAssignment.render(assignmentNode.toString(), work, ccid, lti)).withCookies(newCookie1, newCookie2); - } - else { // Instructor - if (ccid == null) { - if (editKey != null) { // Instructor viewing for editing/submissions - // TODO: Check if there are any submissions? - assignmentNode.put("viewSubmissionsURL", "/private/viewSubmissions/" + assignmentID + "/" + editKey); - String publicURL = prefix + "/assignment/" + assignmentID; - String privateURL = prefix + "/private/assignment/" + assignmentID + "/" + editKey; - String editAssignmentURL = prefix + "/private/editAssignment/" + assignmentID + "/" + editKey; - assignmentNode.put("editAssignmentURL", editAssignmentURL); - assignmentNode.put("privateURL", privateURL); - assignmentNode.put("publicURL", publicURL); - } - String cloneURL = prefix + "/copyAssignment/" + assignmentID; - assignmentNode.put("cloneURL", cloneURL); - } - - return ok(views.html.workAssignment.render(assignmentNode.toString(), work, ccid, lti)); + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } + catch (Exception ex) { + logger.log(Logger.Level.ERROR, Util.getStackTrace(ex)); + return internalServerError(Util.getStackTrace(ex)); } } public Result viewSubmissions(Http.Request request, String assignmentID, String editKey) throws IOException { - ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID); - if (assignmentNode == null) return badRequest("Assignment not found"); - - if (!editKeyValid(editKey, assignmentNode)) - throw new IllegalArgumentException("Edit key does not match"); - - ArrayNode submissions = JsonNodeFactory.instance.arrayNode(); - - Map itemMap = s3conn.readJsonObjectsFromDynamoDB("CodeCheckWork", "assignmentID", assignmentID, "workID"); - - for (String submissionKey : itemMap.keySet()) { - String[] parts = submissionKey.split("/"); - String ccid = parts[0]; - String submissionEditKey = parts[1]; - - ObjectNode work = itemMap.get(submissionKey); - ObjectNode submissionData = JsonNodeFactory.instance.objectNode(); - submissionData.put("opaqueID", ccid); - submissionData.put("score", Assignment.score(assignmentNode, work)); - submissionData.set("submittedAt", work.get("submittedAt")); - submissionData.put("viewURL", "/private/submission/" + assignmentID + "/" + ccid + "/" + submissionEditKey); - submissions.add(submissionData); + try { + String result = assignmentService.viewSubmissions(assignmentID, editKey); + return ok(result).as("text/html"); + } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } + catch (Exception ex) { + logger.log(Logger.Level.ERROR, Util.getStackTrace(ex)); + return internalServerError(Util.getStackTrace(ex)); } - String allSubmissionsURL = "/lti/allSubmissions?resourceID=" + URLEncoder.encode(assignmentID, "UTF-8"); - return ok(views.html.viewSubmissions.render(allSubmissionsURL, submissions.toString())); } /* @@ -332,84 +111,48 @@ public Result viewSubmissions(Http.Request request, String assignmentID, String * New or cloned: Neither request.assignmentID nor request.editKey exist */ public Result saveAssignment(Http.Request request) throws IOException { - ObjectNode params = (ObjectNode) request.body().asJson(); - - try { - params.set("problems", parseAssignment(params.get("problems").asText())); - } catch (IllegalArgumentException e) { - return badRequest(e.getMessage()); + try { + String prefix = controllers.Util.prefix(request); + ObjectNode params = (ObjectNode) request.body().asJson(); + ObjectNode result = assignmentService.saveAssignment(prefix, params); + return ok(result); } - String assignmentID; - String editKey; - ObjectNode assignmentNode; - if (params.has("assignmentID")) { - assignmentID = params.get("assignmentID").asText(); - assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID); - if (assignmentNode == null) return badRequest("Assignment not found"); - - if (!params.has("editKey")) return badRequest("Missing edit key"); - editKey = params.get("editKey").asText(); - if (!editKeyValid(editKey, assignmentNode)) - return badRequest("Edit key does not match"); + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } + catch (Exception ex) { + logger.log(Logger.Level.ERROR, Util.getStackTrace(ex)); + return internalServerError(Util.getStackTrace(ex)); } - else { // New assignment or clone - assignmentID = com.horstmann.codecheck.Util.createPublicUID(); - params.put("assignmentID", assignmentID); - if (params.has("editKey")) - editKey = params.get("editKey").asText(); - else { // LTI assignments have an edit key - editKey = Util.createPrivateUID(); - params.put("editKey", editKey); - } - assignmentNode = null; - } - s3conn.writeJsonObjectToDynamoDB("CodeCheckAssignments", params); - - String prefix = models.Util.prefix(request); - String assignmentURL = prefix + "/private/assignment/" + assignmentID + "/" + editKey; - params.put("viewAssignmentURL", assignmentURL); - - return ok(params); } public Result saveWork(Http.Request request) throws IOException, NoSuchAlgorithmException { - try { - ObjectNode requestNode = (ObjectNode) request.body().asJson(); - ObjectNode result = JsonNodeFactory.instance.objectNode(); - - Instant now = Instant.now(); - String assignmentID = requestNode.get("assignmentID").asText(); - ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID); - if (assignmentNode == null) return badRequest("Assignment not found"); - String workID = requestNode.get("workID").asText(); - String problemID = requestNode.get("tab").asText(); - ObjectNode problemsNode = (ObjectNode) requestNode.get("problems"); - - String submissionID = assignmentID + " " + workID + " " + problemID; - ObjectNode submissionNode = JsonNodeFactory.instance.objectNode(); - submissionNode.put("submissionID", submissionID); - submissionNode.put("submittedAt", now.toString()); - // TODO: NPE in logs for the line below - submissionNode.put("state", problemsNode.get(problemID).get("state").toString()); - submissionNode.put("score", problemsNode.get(problemID).get("score").asDouble()); - s3conn.writeJsonObjectToDynamoDB("CodeCheckSubmissions", submissionNode); - - if (assignmentNode.has("deadline")) { - try { - Instant deadline = Instant.parse(assignmentNode.get("deadline").asText()); - if (now.isAfter(deadline)) - return badRequest("After deadline of " + deadline); - } catch(DateTimeParseException e) { // TODO: This should never happen, but it did - logger.error(Util.getStackTrace(e)); - } - } - result.put("submittedAt", now.toString()); + try { + ObjectNode params = (ObjectNode) request.body().asJson(); + ObjectNode result = assignmentService.saveWork(params); + return ok(result); + } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } + catch (Exception ex) { + logger.log(Logger.Level.ERROR, Util.getStackTrace(ex)); + return internalServerError(Util.getStackTrace(ex)); + } + } - s3conn.writeNewerJsonObjectToDynamoDB("CodeCheckWork", requestNode, "assignmentID", "submittedAt"); - return ok(result); - } catch (Exception e) { - logger.error(Util.getStackTrace(e)); - return badRequest(e.getMessage()); - } - } + public Result saveComment(Http.Request request) throws IOException { + try { + ObjectNode params = (ObjectNode) request.body().asJson(); + ObjectNode result = assignmentService.saveComment(params); + return ok(result); + } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } + catch (Exception ex) { + logger.log(Logger.Level.ERROR, Util.getStackTrace(ex)); + return internalServerError(Util.getStackTrace(ex)); + } + } } diff --git a/app/controllers/Check.java b/app/controllers/Check.java index 129ae249..8961bd91 100644 --- a/app/controllers/Check.java +++ b/app/controllers/Check.java @@ -1,15 +1,9 @@ package controllers; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; -import java.time.Duration; -import java.util.Base64; -import java.util.HashMap; -import java.util.Iterator; import java.util.Map; -import java.util.Map.Entry; import java.util.Optional; import java.util.TreeMap; import java.util.concurrent.CompletableFuture; @@ -18,13 +12,10 @@ import javax.inject.Inject; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import com.horstmann.codecheck.Util; -import models.CodeCheck; -import play.Logger; -import play.libs.Json; +import play.libs.Files.TemporaryFile; import play.libs.concurrent.HttpExecution; import play.mvc.Controller; import play.mvc.Http; @@ -32,139 +23,66 @@ public class Check extends Controller { private CodecheckExecutionContext ccec; - @Inject private CodeCheck codeCheck; + @Inject private services.Check checkService; - - // Classic HTML report, used in Core Java for the Impatient 2e - public CompletableFuture checkHTML(Http.Request request) throws IOException, InterruptedException { - Map params = request.body().asFormUrlEncoded(); + public CompletableFuture run(Http.Request request) throws IOException, InterruptedException { return CompletableFuture.supplyAsync(() -> { try { - String ccid = null; - String repo = "ext"; - String problem = ""; - Map submissionFiles = new TreeMap<>(); - - for (String key : params.keySet()) { - String value = params.get(key)[0]; - if (key.equals("repo")) - repo = value; - else if (key.equals("problem")) - problem = value; - else if (key.equals("ccid")) // TODO: For testing of randomization? - ccid = value; - else + Map params; + Map submissionFiles = new TreeMap<>(); + String contentType = request.contentType().orElse(""); + if ("application/x-www-form-urlencoded".equals(contentType)) { + params = request.body().asFormUrlEncoded(); + for (String key : params.keySet()) { + String value = params.get(key)[0]; submissionFiles.put(Paths.get(key), value); - } - if (ccid == null) { - Optional ccidCookie = request.getCookie("ccid"); - ccid = ccidCookie.map(Http.Cookie::value).orElse(com.horstmann.codecheck.Util.createPronouncableUID()); - } - long startTime = System.nanoTime(); - String report = codeCheck.run("html", repo, problem, ccid, submissionFiles).getText(); - double elapsed = (System.nanoTime() - startTime) / 1000000000.0; - if (report == null || report.length() == 0) { - report = String.format("Timed out after %5.0f seconds\n", elapsed); - } - - Http.Cookie newCookie = Http.Cookie.builder("ccid", ccid).withMaxAge(Duration.ofDays(180)).build(); - return ok(report).withCookies(newCookie).as("text/html"); - } - catch (Exception ex) { - return internalServerError(Util.getStackTrace(ex)); - } - }, HttpExecution.fromThread((Executor) ccec) /* ec.current() */); + } + return ok(checkService.run(submissionFiles)).as("text/plain"); + } else if ("multipart/form-data".equals(contentType)) { + play.mvc.Http.MultipartFormData body = request.body().asMultipartFormData(); + for (var f : body.getFiles()) { + String name = f.getFilename(); + TemporaryFile tempZipFile = f.getRef(); + Path savedPath = tempZipFile.path(); + String contents = Util.read(savedPath); + submissionFiles.put(Paths.get(name), contents); + } + return ok(checkService.run(submissionFiles)).as("text/plain"); + } else if ("application/json".equals(contentType)) { + return ok(checkService.runJSON(request.body().asJson())).as("application/json"); + } + else return internalServerError("Bad content type"); + } catch (Exception ex) { + return internalServerError(Util.getStackTrace(ex)); + } + }, HttpExecution.fromThread((Executor) ccec) /* ec.current() */); } - - // From JS UI + public CompletableFuture checkNJS(Http.Request request) throws IOException, InterruptedException { - Map params; - if ("application/x-www-form-urlencoded".equals(request.contentType().orElse(""))) - params = request.body().asFormUrlEncoded(); - else if ("application/json".equals(request.contentType().orElse(""))) { - params = new HashMap<>(); - JsonNode json = request.body().asJson(); - Iterator> iter = json.fields(); - while (iter.hasNext()) { - Entry entry = iter.next(); - params.put(entry.getKey(), new String[] { entry.getValue().asText() }); - }; - } - else - params = request.queryString(); - return CompletableFuture.supplyAsync(() -> { try { - String ccid = null; - String repo = "ext"; - String problem = null; - String reportType = "NJS"; - String scoreCallback = null; - StringBuilder requestParams = new StringBuilder(); - ObjectNode studentWork = JsonNodeFactory.instance.objectNode(); - Map submissionFiles = new TreeMap<>(); - Map reportZipFiles = new TreeMap<>(); - for (String key : params.keySet()) { - String value = params.get(key)[0]; - - if (requestParams.length() > 0) requestParams.append(", "); - requestParams.append(key); - requestParams.append("="); - int nl = value.indexOf('\n'); - if (nl >= 0) { - requestParams.append(value.substring(0, nl)); - requestParams.append("..."); - } - else requestParams.append(value); - - if ("repo".equals(key)) repo = value; - else if ("problem".equals(key)) problem = value; - else if ("scoreCallback".equals(key)) scoreCallback = value; - else if ("ccid".equals(key)) ccid = value; // TODO: For testing of randomization? - else { - Path p = Paths.get(key); - submissionFiles.put(p, value); - reportZipFiles.put(p, value.getBytes(StandardCharsets.UTF_8)); - studentWork.put(key, value); - } - } - if (ccid == null) { - Optional ccidCookie = request.getCookie("ccid"); - ccid = ccidCookie.map(Http.Cookie::value).orElse(com.horstmann.codecheck.Util.createPronouncableUID()); - }; - //Logger.of("com.horstmann.codecheck.check").info("checkNJS: " + requestParams); - //TODO last param should be submissionDir - String report = codeCheck.run(reportType, repo, problem, ccid, submissionFiles).getText(); - ObjectNode result = (ObjectNode) Json.parse(report); - String reportHTML = result.get("report").asText(); - reportZipFiles.put(Paths.get("report.html"), reportHTML.getBytes(StandardCharsets.UTF_8)); - - byte[] reportZipBytes = codeCheck.signZip(reportZipFiles); - - // TODO Need to sign - String reportZip = Base64.getEncoder().encodeToString(reportZipBytes); - - //TODO: Score callback no longer used from LTIHub. Does Engage use it? - if (scoreCallback != null) { - if (scoreCallback.startsWith("https://")) - scoreCallback = "http://" + scoreCallback.substring("https://".length()); // TODO: Fix - - //TODO: Add to result the student submissions - ObjectNode augmentedResult = result.deepCopy(); - augmentedResult.set("studentWork", studentWork); - - String resultText = Json.stringify(augmentedResult); - Logger.of("com.horstmann.codecheck.lti").info("Request: " + scoreCallback + " " + resultText); - String response = com.horstmann.codecheck.Util.httpPost(scoreCallback, resultText, "application/json"); - Logger.of("com.horstmann.codecheck.lti").info("Response: " + response); - } - - result.put("zip", reportZip); - Http.Cookie newCookie = Http.Cookie.builder("ccid", ccid).withMaxAge(Duration.ofDays(180)).build(); + JsonNode json = request.body().asJson(); + Optional ccidCookie = request.getCookie("ccid"); + String ccid = ccidCookie.map(Http.Cookie::value).orElse(Util.createPronouncableUID()); + ObjectNode result = checkService.checkNJS(json, ccid); + Http.Cookie newCookie = controllers.Util.buildCookie("ccid", ccid); return ok(result).withCookies(newCookie).as("application/json"); } catch (Exception ex) { return internalServerError(Util.getStackTrace(ex)); } }, HttpExecution.fromThread((Executor) ccec) /* ec.current() */); - } + } + + public CompletableFuture setupReport(Http.Request request, String repo, String problem) throws IOException, InterruptedException { + return CompletableFuture.supplyAsync(() -> { + try { + String ccid = Util.createPronouncableUID(); + String report = checkService.setupReport(repo, problem, ccid); + return ok(report).as("application/json"); + } + catch (Exception ex) { + return internalServerError(Util.getStackTrace(ex)); + } + }, HttpExecution.fromThread((Executor) ccec) /* ec.current() */); + } } diff --git a/app/controllers/Config.java b/app/controllers/Config.java new file mode 100644 index 00000000..f57e98ed --- /dev/null +++ b/app/controllers/Config.java @@ -0,0 +1,41 @@ +package controllers; + +import java.io.IOException; +import java.io.InputStream; +import java.sql.Connection; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.horstmann.codecheck.ResourceLoader; + +import play.db.Database; +import play.db.Databases; + +@Singleton public class Config implements ResourceLoader { + @Inject private com.typesafe.config.Config config; + @Inject private play.api.Environment playEnv; + private Database db; + + public InputStream loadResource(String path) throws IOException { + return playEnv.classLoader().getResourceAsStream("public/resources/" + path); + } + public String getProperty(String key) { + return config.hasPath(key) ? config.getString(key) : null; + } + public String getString(String key) { // TODO Why do we need both getString and getProperty? + return getProperty(key); + } + public boolean hasPath(String key) { + return getProperty(key) != null; + } + + // TODO: Thread safety + public Connection getDatabaseConnection() { + if (db == null) { + String driver = config.getString("com.horstmann.corejava.sql.driver"); + String url = config.getString("com.horstmann.corejava.sql.url"); + db = Databases.createFrom(driver, url); + } + return db.getConnection(); + } +} \ No newline at end of file diff --git a/app/controllers/Files.java b/app/controllers/Files.java index 4ae57e80..a942bbf0 100644 --- a/app/controllers/Files.java +++ b/app/controllers/Files.java @@ -1,310 +1,63 @@ package controllers; import java.io.IOException; -import java.net.URL; -import java.nio.file.Path; -import java.text.MessageFormat; -import java.time.Duration; -import java.util.List; -import java.util.Map; import java.util.Optional; import javax.inject.Inject; import javax.script.ScriptException; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.horstmann.codecheck.Problem; import com.horstmann.codecheck.Util; -import com.typesafe.config.Config; -import models.CodeCheck; -import play.Logger; import play.mvc.Controller; import play.mvc.Http; import play.mvc.Result; +import services.ServiceException; public class Files extends Controller { - @Inject private CodeCheck codeCheck; - private static Logger.ALogger logger = Logger.of("com.horstmann.codecheck"); - @Inject private Config config; + @Inject private services.Files filesService; - String start2 = "\n\n" - + "CodeCheck" - + "\n" - + "\n" - + "\n" - + "\n" - + "\n" - + "\n" - + "\n" - + "\n" - + "\n" - + "\n"; - String mid2 = "
\n" - + "\n" - + ""; - - public Result filesHTML2(Http.Request request, String repo, String problemName, String ccid) + public Result filesHTML2(Http.Request request, String repo, String problemName) throws IOException, NoSuchMethodException, ScriptException { - if (ccid == null) { - Optional ccidCookie = request.getCookie("ccid"); - ccid = ccidCookie.map(Http.Cookie::value).orElse(Util.createPronouncableUID()); - } - Map problemFiles; - try { - problemFiles = codeCheck.loadProblem(repo, problemName, ccid); - } catch (Exception e) { - logger.error("filesHTML2: Cannot load problem " + repo + "/" + " " + problemName + e.getMessage()); - return badRequest("Cannot load problem"); - } - Problem problem = new Problem(problemFiles); - ObjectNode data = models.Util.toJson(problem.getProblemData()); - data.put("url", models.Util.prefix(request) + "/checkNJS"); - data.put("repo", repo); - data.put("problem", problemName); - String description = ""; - if (data.has("description")) { - description = data.get("description").asText(); - data.remove("description"); - } - StringBuilder result = new StringBuilder(); - result.append(start2); - result.append(description); - result.append(mid2); - result.append(data.toString()); - result.append(end2); - wakeupChecker(); - Http.Cookie newCookie = Http.Cookie.builder("ccid", ccid).withMaxAge(Duration.ofDays(180)).build(); - return ok(result.toString()).withCookies(newCookie).as("text/html"); + try { + Optional ccidCookie = request.getCookie("ccid"); + String ccid = ccidCookie.map(Http.Cookie::value).orElse(Util.createPronouncableUID()); + String result = filesService.filesHTML2(controllers.Util.prefix(request), repo, problemName, ccid); + Http.Cookie newCookie = controllers.Util.buildCookie("ccid", ccid); + return ok(result).withCookies(newCookie).as("text/html"); + } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } } - private void wakeupChecker() { - // Wake up the checker - String path = "com.horstmann.codecheck.comrun.remote"; - if (!config.hasPath(path)) return; - String remoteURL = config.getString(path); - if (remoteURL.isBlank()) return; - new Thread(() -> { try { - URL checkerWakeupURL = new URL(remoteURL + "/api/health"); - checkerWakeupURL.openStream().readAllBytes(); - } catch (IOException e) { - e.printStackTrace(); - } }).start(); - } - - private static String tracerStart = "\n" - + "\n" - + "\n" - + " \n" - + " " - + " CodeCheck Tracer\n" - + " \n" - + "\n" - + "\n"; - private static String tracerScriptStart = "
\n" - + " \n" - + "
\n" - + "\n" - + ""; - - public Result tracer(Http.Request request, String repo, String problemName, String ccid) + public Result tracer(Http.Request request, String repo, String problemName) throws IOException { - if (ccid == null) { - Optional ccidCookie = request.getCookie("ccid"); - ccid = ccidCookie.map(Http.Cookie::value).orElse(Util.createPronouncableUID()); - } - Map problemFiles; - try { - problemFiles = codeCheck.loadProblem(repo, problemName, ccid); - } catch (Exception e) { - logger.error("filesHTML: Cannot load problem " + repo + "/" + problemName + " " + e.getMessage()); - return badRequest("Cannot load problem " + repo + "/" + problemName); - } - Problem problem = new Problem(problemFiles); - Problem.DisplayData data = problem.getProblemData(); - StringBuilder result = new StringBuilder(); - result.append(tracerStart); - if (data.description != null) - result.append(data.description); - result.append(tracerScriptStart); - result.append(Util.getString(problemFiles, Path.of("tracer.js"))); - result.append(tracerEnd); - - Http.Cookie newCookie = Http.Cookie.builder("ccid", ccid).withMaxAge(Duration.ofDays(180)).build(); - return ok(result.toString()).withCookies(newCookie).as("text/html"); + try { + Optional ccidCookie = request.getCookie("ccid"); + String ccid = ccidCookie.map(Http.Cookie::value).orElse(Util.createPronouncableUID()); + String result = filesService.tracer(repo, problemName, ccid); + Http.Cookie newCookie = controllers.Util.buildCookie("ccid", ccid); + return ok(result).withCookies(newCookie).as("text/html"); + } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } } // TODO: Caution--this won't do the right thing with param.js randomness when // used to prebuild UI like in ebook, Udacity - public Result fileData(Http.Request request, String repo, String problemName, String ccid) + public Result fileData(Http.Request request, String repo, String problemName) throws IOException, NoSuchMethodException, ScriptException { - if (ccid == null) { - Optional ccidCookie = request.getCookie("ccid"); - ccid = ccidCookie.map(Http.Cookie::value).orElse(Util.createPronouncableUID()); - } - Map problemFiles; - try { - problemFiles = codeCheck.loadProblem(repo, problemName, ccid); - } catch (Exception e) { - logger.error("fileData: Cannot load problem " + repo + "/" + problemName + " " + e.getMessage()); - return badRequest("Cannot load problem"); - } - - Problem problem = new Problem(problemFiles); - Http.Cookie newCookie = Http.Cookie.builder("ccid", ccid).withMaxAge(Duration.ofDays(180)).build(); - return ok(models.Util.toJson(problem.getProblemData())).withCookies(newCookie); + try { + Optional ccidCookie = request.getCookie("ccid"); + String ccid = ccidCookie.map(Http.Cookie::value).orElse(Util.createPronouncableUID()); + ObjectNode result = filesService.fileData(repo, problemName, ccid); + Http.Cookie newCookie = controllers.Util.buildCookie("ccid", ccid); + return ok(result).withCookies(newCookie); + } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } } - - // TODO: Legacy, also codecheck.js - private static String start = "\n\n" - + "\n" - + "\n" - + "\n" - + "\n" - + "\n" - + "\n"; - private static String before = "
\n"; - - private static String fileAreaBefore = "
"; - private static String fileAreaBeforeNoEdit = "
"; - private static String fileAreaAfter = "
\n"; - - private static String fileOuterDiv = "
\n"; - private static String fileOuterDivAfter = "
\n"; - - private static String after = "
\n" - + "\n" - + "\n"; - private static String formEnd = "\n
\n"; - private static String bodyEnd = ""; - - private static String useStart = "

Use the following {0,choice,1#file|2#files}:

\n"; - private static String provideStart = "

Complete the following {0,choice,1#file|2#files}:

\n"; - - public Result filesHTML(Http.Request request, String repo, String problemName, String ccid) - throws IOException, NoSuchMethodException, ScriptException { - if (ccid == null) { - Optional ccidCookie = request.getCookie("ccid"); - ccid = ccidCookie.map(Http.Cookie::value).orElse(Util.createPronouncableUID()); - } - Map problemFiles; - try { - problemFiles = codeCheck.loadProblem(repo, problemName, ccid); - } catch (Exception e) { - logger.error("filesHTML: Cannot load problem " + repo + "/" + problemName + " " + e.getMessage()); - return badRequest("Cannot load problem " + repo + "/" + problemName); - } - Problem problem = new Problem(problemFiles); - Problem.DisplayData data = problem.getProblemData(); - StringBuilder result = new StringBuilder(); - result.append(start); - - if (data.description != null) - result.append(data.description); - String contextPath = ""; // request.host(); // TODO - String url = contextPath + "/check"; - result.append(MessageFormat.format(before, url)); - result.append(MessageFormat.format(provideStart, data.requiredFiles.size())); - - for (Map.Entry entry : data.requiredFiles.entrySet()) { - String file = entry.getKey(); - List conts = entry.getValue().editors; - - if (file.equals("Input") && conts.get(0).trim().length() == 0) { - // Make a hidden field with blank input - result.append(""); - } else { - boolean firstTitle = true; - int textAreaNumber = 0; - String appended; - // int continuingLines = 0; - boolean editable = true; - for (String cont : conts) { - if (cont == null) { // only the case for the first time to skip editable - editable = false; - } else { - int lines = 0; - textAreaNumber++; - appended = file + "-" + textAreaNumber; - lines = Util.countLines(cont); - if (lines == 0) - lines = 20; - - if (editable) { - if (firstTitle) { - result.append(MessageFormat.format(fileOuterDiv, file)); - result.append("

"); - result.append(file); - result.append("

"); - firstTitle = false; - } - // result.append(MessageFormat.format(startNumberLines, "editor", - // "firstLineNumber", continuingLines)); - result.append(MessageFormat.format(fileAreaBefore, appended, lines, "java")); - // TODO support more than "java" in ace editor format - result.append(Util - .removeTrailingNewline(Util.htmlEscape(cont))); - result.append(fileAreaAfter); - editable = false; - } else { - if (firstTitle) { - result.append(MessageFormat.format(fileOuterDiv, file)); - result.append("

"); - result.append(file); - result.append("

"); - firstTitle = false; - } - - String s = cont; - int max = 20; - while (s.indexOf("\n") != -1) { - if ((s.substring(0, s.indexOf("\n"))).length() > max) { - max = (s.substring(0, s.indexOf("\n"))).length(); - } - s = s.substring(s.indexOf("\n") + 1); - } - if (s.length() > max) { - max = s.length(); - } - - result.append(MessageFormat.format(fileAreaBeforeNoEdit, appended, lines, max, "java")); - // TODO: support more than "java" in ace editor format - result.append(Util - .removeTrailingNewline(Util.htmlEscape(cont))); - result.append(fileAreaAfter); - editable = true; - } - } - } - result.append(fileOuterDivAfter); - } - } - result.append(MessageFormat.format(after, repo, problemName)); - result.append(formEnd); - - int nusefiles = data.useFiles.size(); - if (nusefiles > 0) { - result.append(MessageFormat.format(useStart, nusefiles)); - for (Map.Entry entry : data.useFiles.entrySet()) { - result.append("

"); - result.append(entry.getKey()); - result.append("

\n"); - result.append("
");
-                result.append(Util.htmlEscape(entry.getValue()));
-                result.append("");
-            }
-        }
-
-        // result.append(jsonpAjaxSubmissionScript);
-        result.append(bodyEnd);
-
-        Http.Cookie newCookie = Http.Cookie.builder("ccid", ccid).withMaxAge(Duration.ofDays(180)).build();
-        return ok(result.toString()).withCookies(newCookie).as("text/html");
-    }       
 }
diff --git a/app/controllers/LTIAssignment.java b/app/controllers/LTIAssignment.java
index 2abb93cd..fcb1c0d7 100644
--- a/app/controllers/LTIAssignment.java
+++ b/app/controllers/LTIAssignment.java
@@ -1,35 +1,18 @@
 package controllers;
 
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.net.URISyntaxException;
-import java.net.URLEncoder;
-import java.net.UnknownHostException;
-import java.security.NoSuchAlgorithmException;
-import java.time.Instant;
-import java.time.format.DateTimeParseException;
+import java.lang.System.Logger;
 import java.util.Map;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
 import javax.inject.Inject;
 
-import com.fasterxml.jackson.databind.node.ArrayNode;
-import com.fasterxml.jackson.databind.node.JsonNodeFactory;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.horstmann.codecheck.Util;
 
-import models.JWT;
-import models.LTI;
-import models.S3Connection;
-import oauth.signpost.exception.OAuthCommunicationException;
-import oauth.signpost.exception.OAuthExpectationFailedException;
-import oauth.signpost.exception.OAuthMessageSignerException;
-import play.Logger;
 import play.mvc.Controller;
 import play.mvc.Http;
 import play.mvc.Result;
 import play.mvc.Security;
+import services.ServiceException;
 
    
 /*
@@ -42,349 +25,171 @@ Session cookie (LTI Instructors only)
 
 
 public class LTIAssignment extends Controller {
-    @Inject private S3Connection s3conn;
-    @Inject private LTI lti;
-    @Inject private JWT jwt;
-    private static Logger.ALogger logger = Logger.of("com.horstmann.codecheck");
+    @Inject private services.LTIAssignment assignmentService;
     
-    public static boolean isInstructor(Map postParams) {
-        String role = com.horstmann.codecheck.Util.getParam(postParams, "roles");
-        return role != null && (role.contains("Faculty") || role.contains("TeachingAssistant") || role.contains("Instructor"));
-    }
+    private static Logger logger = System.getLogger("com.horstmann.codecheck");     
     
-    public Result config(Http.Request request) throws UnknownHostException {
+    public Result config(Http.Request request) {
         String host = request.host();
-        if (host.endsWith("/")) host = host.substring(0, host.length() - 1);
-        return ok(views.xml.lti_config.render(host)).as("application/xml");         
+        String result = assignmentService.config(host);
+        return ok(result).as("application/xml");         
     }     
-
-    private String assignmentOfResource(String resourceID) throws IOException {
-        if (resourceID.contains(" ") ) {
-            int i = resourceID.lastIndexOf(" ");
-            return resourceID.substring(i + 1);
-        } else {
-            ObjectNode resourceNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckLTIResources", "resourceID", resourceID); 
-            if (resourceNode == null) return null;
-            return resourceNode.get("assignmentID").asText();
-        }
-    }   
     
-    /*
-     * Called from Canvas and potentially other LMS with a "resource selection" interface
-     */
-    public Result createAssignment(Http.Request request) throws UnsupportedEncodingException {    
-        Map postParams = request.body().asFormUrlEncoded();
-        if (!lti.validate(request)) {
-            return badRequest("Failed OAuth validation");
-        }       
-        
-        if (!isInstructor(postParams)) 
-            return badRequest("Instructor role is required to create an assignment.");
-        String userID = com.horstmann.codecheck.Util.getParam(postParams, "user_id");
-        if (com.horstmann.codecheck.Util.isEmpty(userID)) return badRequest("No user id");
-
-        String toolConsumerID = com.horstmann.codecheck.Util.getParam(postParams, "tool_consumer_instance_guid");
-        String userLMSID = toolConsumerID + "/" + userID;
-
-        ObjectNode assignmentNode = JsonNodeFactory.instance.objectNode();
-        
-        String launchPresentationReturnURL = com.horstmann.codecheck.Util.getParam(postParams, "launch_presentation_return_url");
-        assignmentNode.put("launchPresentationReturnURL", launchPresentationReturnURL);
-        assignmentNode.put("saveURL", "/lti/saveAssignment");
-        assignmentNode.put("assignmentID", com.horstmann.codecheck.Util.createPublicUID());
-        assignmentNode.put("editKey", userLMSID);
-        
-        return ok(views.html.editAssignment.render(assignmentNode.toString(), false));
+    public Result createAssignment(Http.Request request) {
+    	try {
+	        String url = controllers.Util.prefix(request) + request.uri();
+	        Map postParams = request.body().asFormUrlEncoded();
+	        String result = assignmentService.createAssignment(url, postParams);
+    		return ok(result).as("text/html");
+        }
+    	catch (ServiceException ex) {
+    		return badRequest(ex.getMessage());
+    	}
+        catch (Exception ex) {
+            logger.log(Logger.Level.ERROR, Util.getStackTrace(ex));
+            return internalServerError(Util.getStackTrace(ex));
+        }
     }
 
-    private static String assignmentIDifAssignmentURL(String url) {
-        if (url.contains("\n")) return null;
-        Pattern pattern = Pattern.compile("https?://codecheck.[a-z]+/(private/)?(a|viewA)ssignment/([a-z0-9]+)($|/).*");
-        Matcher matcher = pattern.matcher(url);
-        return matcher.matches() ? matcher.group(3) : null; 
-    }
-    
-    public Result saveAssignment(Http.Request request) throws IOException {         
-        ObjectNode params = (ObjectNode) request.body().asJson();
-                    
-        String problemText = params.get("problems").asText().trim();
-        String assignmentID = assignmentIDifAssignmentURL(problemText);
-        if (assignmentID == null) {             
-            try {
-                params.set("problems", Assignment.parseAssignment(problemText));
-            } catch (IllegalArgumentException e) {
-                return badRequest(e.getMessage());
-            }
-    
-            assignmentID = params.get("assignmentID").asText();
-            ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID);
-            String editKey = params.get("editKey").asText();
-            
-            if (assignmentNode != null && !editKey.equals(assignmentNode.get("editKey").asText())) 
-                return badRequest("Edit keys do not match");        
-    
-            s3conn.writeJsonObjectToDynamoDB("CodeCheckAssignments", params);
+    public Result saveAssignment(Http.Request request) {
+    	try {
+    		ObjectNode params = (ObjectNode) request.body().asJson();
+    		String host = request.host();
+    		ObjectNode result = assignmentService.saveAssignment(host, params);
+    		return ok(result);
+    	}
+    	catch (ServiceException ex) {
+    		return badRequest(ex.getMessage());
+    	}
+        catch (Exception ex) {
+            logger.log(Logger.Level.ERROR, Util.getStackTrace(ex));
+            return internalServerError(Util.getStackTrace(ex));
         }
-
-        ObjectNode result = JsonNodeFactory.instance.objectNode();
-        String viewAssignmentURL = "/viewAssignment/" + assignmentID; 
-        result.put("viewAssignmentURL", viewAssignmentURL);     
-        String launchURL = "https://" + request.host() + "/assignment/" + assignmentID;
-        result.put("launchURL", launchURL);     
-
-        return ok(result); // Client will redirect to launch presentation URL 
     }
     
     @Security.Authenticated(Secured.class) // Instructor
-    public Result viewSubmissions(Http.Request request) throws IOException {
-        String resourceID = request.queryString("resourceID").orElse(null);
-        Map itemMap = s3conn.readJsonObjectsFromDynamoDB("CodeCheckWork", "assignmentID", resourceID, "workID");
-        String assignmentID = assignmentOfResource(resourceID);
-        
-        ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID);
-        if (assignmentNode == null) return badRequest("Assignment not found");
-
-        ArrayNode submissions = JsonNodeFactory.instance.arrayNode();
-        for (String workID : itemMap.keySet()) {
-            ObjectNode work = itemMap.get(workID);
-            ObjectNode submissionData = JsonNodeFactory.instance.objectNode();
-            submissionData.put("opaqueID", workID);
-            submissionData.put("score", Assignment.score(assignmentNode, work));
-            submissionData.set("submittedAt", work.get("submittedAt"));
-            submissionData.put("viewURL", "/lti/viewSubmission?resourceID=" 
-                    + URLEncoder.encode(resourceID, "UTF-8") 
-                    + "&workID=" + URLEncoder.encode(workID, "UTF-8")); 
-            submissions.add(submissionData);
+    public Result viewSubmissions(Http.Request request) {
+    	try {
+    		String resourceID = request.queryString("resourceID").orElse(null);
+    		String result = assignmentService.viewSubmissions(resourceID);
+    		return ok(result).as("text/html");
+        }
+    	catch (ServiceException ex) {
+    		return badRequest(ex.getMessage());
+    	}
+        catch (Exception ex) {
+            logger.log(Logger.Level.ERROR, Util.getStackTrace(ex));
+            return internalServerError(Util.getStackTrace(ex));
         }
-        String allSubmissionsURL = "/lti/allSubmissions?resourceID=" + URLEncoder.encode(resourceID, "UTF-8");
-        return ok(views.html.viewSubmissions.render(allSubmissionsURL, submissions.toString()));    
     }
     
     @Security.Authenticated(Secured.class) // Instructor
-    public Result viewSubmission(Http.Request request) throws IOException {
-        String resourceID = request.queryString("resourceID").orElse(null);
-        String workID = request.queryString("workID").orElse(null);
-        String work = s3conn.readJsonStringFromDynamoDB("CodeCheckWork", "assignmentID", resourceID, "workID", workID);
-        if (work == null) return badRequest("Work not found");
-        String assignmentID = assignmentOfResource(resourceID);
-        ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID);
-        if (assignmentNode == null) return badRequest("Assignment not found");
-        ArrayNode groups = (ArrayNode) assignmentNode.get("problems");
-        assignmentNode.set("problems", groups.get(Math.abs(workID.hashCode()) % groups.size()));
-        
-        return ok(views.html.workAssignment.render(assignmentNode.toString(), work, workID, "undefined"));
+    public Result viewSubmission(Http.Request request) {
+    	try {
+	        String resourceID = request.queryString("resourceID").orElse(null);
+	        String workID = request.queryString("workID").orElse(null);
+	        String result = assignmentService.viewSubmission(resourceID, workID);
+    		return ok(result).as("text/html");
+        }
+    	catch (ServiceException ex) {
+    		return badRequest(ex.getMessage());
+    	}
+        catch (Exception ex) {
+            logger.log(Logger.Level.ERROR, Util.getStackTrace(ex));
+            return internalServerError(Util.getStackTrace(ex));
+        }
     }
     
     @Security.Authenticated(Secured.class) // Instructor
-    public Result editAssignment(Http.Request request, String assignmentID) throws IOException {
-        String editKey = request.session().get("user").get(); // TODO orElseThrow();    
-        ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID);
-        if (assignmentNode == null) return badRequest("Assignment not found");
-        
-        if (!editKey.equals(assignmentNode.get("editKey").asText())) 
-            return badRequest("Edit keys don't match");
-        assignmentNode.put("saveURL", "/lti/saveAssignment");       
-        return ok(views.html.editAssignment.render(assignmentNode.toString(), false));      
-    }
-    
-    public Result launch(Http.Request request, String assignmentID) throws IOException {    
-        Map postParams = request.body().asFormUrlEncoded();
-        if (!lti.validate(request)) {
-            return badRequest("Failed OAuth validation");
-        }       
-        
-        String userID = com.horstmann.codecheck.Util.getParam(postParams, "user_id");
-        if (com.horstmann.codecheck.Util.isEmpty(userID)) return badRequest("No user id");
-
-        String toolConsumerID = com.horstmann.codecheck.Util.getParam(postParams, "tool_consumer_instance_guid");
-        String contextID = com.horstmann.codecheck.Util.getParam(postParams, "context_id");
-        String resourceLinkID = com.horstmann.codecheck.Util.getParam(postParams, "resource_link_id");
-
-        String userLMSID = toolConsumerID + "/" + userID;
-
-        ObjectNode ltiNode = JsonNodeFactory.instance.objectNode();
-        // TODO: In order to facilitate search by assignmentID, it would be better if this was the other way around
-        String resourceID = toolConsumerID + "/" + contextID + " " + assignmentID; 
-        String legacyResourceID = toolConsumerID + "/" + contextID + "/" + resourceLinkID; 
-        ObjectNode resourceNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckLTIResources", "resourceID", legacyResourceID); 
-        if (resourceNode != null) resourceID = legacyResourceID;
-        
-        //TODO: Query string legacy
-        if (assignmentID == null)
-            assignmentID = request.queryString("id").orElse(null);
-
-        if (assignmentID == null) {
-            return badRequest("No assignment ID");
-        } 
-        if (isInstructor(postParams)) {     
-            ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID);
-            if (assignmentNode == null) return badRequest("Assignment not found");
-            ArrayNode groups = (ArrayNode) assignmentNode.get("problems");
-            assignmentNode.set("problems", groups.get(0));
-            String assignmentEditKey = assignmentNode.get("editKey").asText();
-            
-            assignmentNode.put("isStudent", false);
-            assignmentNode.put("viewSubmissionsURL", "/lti/viewSubmissions?resourceID=" + URLEncoder.encode(resourceID, "UTF-8"));
-            if (userLMSID.equals(assignmentEditKey)) {
-                assignmentNode.put("editAssignmentURL", "/lti/editAssignment/" + assignmentID);
-                assignmentNode.put("cloneURL", "/copyAssignment/" + assignmentID);
-            }
-            assignmentNode.put("sentAt", Instant.now().toString());             
-            String work = "{ problems: {} }";
-            // Show the resource ID for troubleshooting
-            return ok(views.html.workAssignment.render(assignmentNode.toString(), work, resourceID, "undefined" /* lti */))
-                .withNewSession()
-                .addingToSession(request, "user", userLMSID);
-        } else { // Student
-            ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID);
-            if (assignmentNode == null) return badRequest("Assignment not found");
-            ArrayNode groups = (ArrayNode) assignmentNode.get("problems");
-            assignmentNode.set("problems", groups.get(Math.abs(userID.hashCode()) % groups.size()));
-            assignmentNode.remove("editKey");
-
-            String lisOutcomeServiceURL = com.horstmann.codecheck.Util.getParam(postParams, "lis_outcome_service_url");
-            String lisResultSourcedID = com.horstmann.codecheck.Util.getParam(postParams, "lis_result_sourcedid");
-            String oauthConsumerKey = com.horstmann.codecheck.Util.getParam(postParams, "oauth_consumer_key");
-            
-            if (com.horstmann.codecheck.Util.isEmpty(lisOutcomeServiceURL)) 
-                return badRequest("lis_outcome_service_url missing.");
-            else
-                ltiNode.put("lisOutcomeServiceURL", lisOutcomeServiceURL);
-            
-            if (com.horstmann.codecheck.Util.isEmpty(lisResultSourcedID)) 
-                return badRequest("lis_result_sourcedid missing.");
-            else
-                ltiNode.put("lisResultSourcedID", lisResultSourcedID);
-            ltiNode.put("oauthConsumerKey", oauthConsumerKey);
-            ltiNode.put("jwt", jwt.generate(Map.of("resourceID", resourceID, "userID", userID)));
-
-            ObjectNode workNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckWork", "assignmentID", resourceID, "workID", userID);
-            String work = "";
-            if (workNode == null) 
-                work = "{ problems: {} }";
-            else {
-                // Delete assignmentID, workID since they are in jwt token
-                workNode.remove("assignmentID");
-                workNode.remove("workID");
-                work = workNode.toString();
-            }
-            
-            assignmentNode.put("isStudent", true);
-            assignmentNode.put("editKeySaved", true);
-            assignmentNode.put("sentAt", Instant.now().toString());     
-
-            return ok(views.html.workAssignment.render(assignmentNode.toString(), work, userID, ltiNode.toString()));
+    public Result editAssignment(Http.Request request, String assignmentID) {
+    	try {
+    		String editKey = request.session().get("user").get(); // TODO orElseThrow();    
+    		String result = assignmentService.editAssignment(assignmentID, editKey);
+    		return ok(result).as("text/html");
+        }
+    	catch (ServiceException ex) {
+    		return badRequest(ex.getMessage());
+    	}
+        catch (Exception ex) {
+            logger.log(Logger.Level.ERROR, Util.getStackTrace(ex));
+            return internalServerError(Util.getStackTrace(ex));
+        }
+	}
+
+    public Result launch(Http.Request request, String assignmentID) {
+    	try {
+	        String url = controllers.Util.prefix(request) + request.uri();
+	        if (assignmentID == null) //TODO: Query string id legacy
+	            assignmentID = request.queryString("id").orElse(null);
+	        if (assignmentID == null) // Bridge
+	            assignmentID = request.queryString("url").orElse(null);
+	        Map postParams = request.body().asFormUrlEncoded();        
+	        String result = assignmentService.launch(url, assignmentID, postParams);
+	        if (services.LTIAssignment.isInstructor(postParams)) {     
+	            String toolConsumerID = Util.getParam(postParams, "tool_consumer_instance_guid");
+	            String userID = Util.getParam(postParams, "user_id");
+	            String userLMSID = toolConsumerID + "/" + userID;
+	        	
+	            return ok(result)
+	            	.as("text/html")             
+	                .withNewSession()
+	                .addingToSession(request, "user", userLMSID);
+	        } else { // Student
+	            return ok(result).as("text/html");
+	        }
+        }
+    	catch (ServiceException ex) {
+    		return badRequest(ex.getMessage());
+    	}
+        catch (Exception ex) {
+            logger.log(Logger.Level.ERROR, Util.getStackTrace(ex));
+            return internalServerError(Util.getStackTrace(ex));
         }
     }
     
     @Security.Authenticated(Secured.class) // Instructor
-    public Result allSubmissions(Http.Request request) throws IOException {
-        String resourceID = request.queryString("resourceID").orElse(null);
-        if (resourceID == null) return badRequest("Assignment not found");
-        Map itemMap = s3conn.readJsonObjectsFromDynamoDB("CodeCheckWork", "assignmentID", resourceID, "workID");
-        String assignmentID = assignmentOfResource(resourceID);
-        
-        ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID);
-        if (assignmentNode == null) return badRequest("Assignment not found");
-
-        ObjectNode submissions = JsonNodeFactory.instance.objectNode();
-        for (String workID : itemMap.keySet()) {
-            ObjectNode work = itemMap.get(workID);
-            submissions.set(workID, work);
+    public Result allSubmissions(Http.Request request) {
+    	try {
+	        String resourceID = request.queryString("resourceID").orElse(null);
+	        ObjectNode result = assignmentService.allSubmissions(resourceID);
+	        return ok(result);
+    	} 
+    	catch (ServiceException ex) {
+    		return badRequest(ex.getMessage());
+    	}
+        catch (Exception ex) {
+            logger.log(Logger.Level.ERROR, Util.getStackTrace(ex));
+            return internalServerError(Util.getStackTrace(ex));
         }
-        return ok(submissions);     
     }
     
-    public Result saveWork(Http.Request request) throws IOException, NoSuchAlgorithmException {
-        ObjectNode requestNode = (ObjectNode) request.body().asJson();
+    public Result saveWork(Http.Request request) {
         try {
-            ObjectNode workNode = (ObjectNode) requestNode.get("work");
-            String token = requestNode.get("jwt").asText();
-            Map claims = jwt.verify(token);
-            if (claims == null) 
-                return badRequest("Invalid token");
-            
-            ObjectNode result = JsonNodeFactory.instance.objectNode();
-            Instant now = Instant.now();
-            String resourceID = claims.get("resourceID").toString();
-            workNode.put("assignmentID", resourceID);
-            String assignmentID = assignmentOfResource(resourceID);
-            String userID = claims.get("userID").toString();
-            workNode.put("workID", userID);
-            String problemID = workNode.get("tab").asText();
-            ObjectNode problemsNode = (ObjectNode) workNode.get("problems");
-            ObjectNode submissionNode = JsonNodeFactory.instance.objectNode();
-            String submissionID = resourceID + " " + userID + " " + problemID; 
-            submissionNode.put("submissionID", submissionID);
-            submissionNode.put("submittedAt", now.toString());
-            submissionNode.put("state", problemsNode.get(problemID).get("state").toString());
-            submissionNode.put("score", problemsNode.get(problemID).get("score").asDouble());
-            s3conn.writeJsonObjectToDynamoDB("CodeCheckSubmissions", submissionNode);
-            
-            ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID);
-            if (assignmentNode.has("deadline")) {
-                try {
-                    Instant deadline = Instant.parse(assignmentNode.get("deadline").asText());
-                    if (now.isAfter(deadline)) 
-                        return badRequest("After deadline of " + deadline);
-                } catch(DateTimeParseException e) { // TODO: This should never happen, but it did
-                    logger.error(Util.getStackTrace(e));
-                }
-            }
-            result.put("submittedAt", now.toString());      
-
-            s3conn.writeNewerJsonObjectToDynamoDB("CodeCheckWork", workNode, "assignmentID", "submittedAt");
-            submitGradeToLMS(requestNode, (ObjectNode) requestNode.get("work"), result);
+            ObjectNode requestNode = (ObjectNode) request.body().asJson();
+            ObjectNode result = assignmentService.saveWork(requestNode);
             return ok(result);
-        } catch (Exception e) {
-            logger.error("saveWork: " + requestNode + " " + e.getMessage());
-            return badRequest("saveWork: " + requestNode);
+        } 
+    	catch (ServiceException ex) {
+    		return badRequest(ex.getMessage());
+    	}
+        catch (Exception ex) {
+            logger.log(Logger.Level.ERROR, Util.getStackTrace(ex));
+            return internalServerError(Util.getStackTrace(ex));
         }
     }   
     
-    public Result sendScore(Http.Request request) throws IOException, NoSuchAlgorithmException {
-        ObjectNode requestNode = (ObjectNode) request.body().asJson();
-        ObjectNode result = JsonNodeFactory.instance.objectNode();
-        result.put("submittedAt", Instant.now().toString());        
-        try {
-            String token = requestNode.get("jwt").asText();
-            Map claims = jwt.verify(token);
-            if (claims == null) 
-                return badRequest("Invalid token");
-
-            String userID = claims.get("userID").toString();
-            String resourceID = claims.get("resourceID").toString();
-            
-            ObjectNode workNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckWork", "assignmentID", resourceID, "workID", userID);
-            if (workNode == null) return badRequest("Work not found");
-            submitGradeToLMS(requestNode, workNode, result);
-            String outcome = result.get("outcome").asText();
-            if (!outcome.startsWith("success")) {
-                logger.error("sendScore: " + requestNode);
-                return badRequest(outcome);
-            }
+    public Result sendScore(Http.Request request) {
+    	try {
+    		ObjectNode requestNode = (ObjectNode) request.body().asJson();
+            ObjectNode result = assignmentService.sendScore(requestNode);
             return ok(result);
-        } catch (Exception e) {
-            logger.error("sendScore: " + requestNode + " " + e.getMessage());
-            return badRequest("sendScore: " + requestNode);
-        }
-    }   
-    
-    private void submitGradeToLMS(ObjectNode params, ObjectNode work, ObjectNode result) 
-            throws IOException, OAuthMessageSignerException, OAuthExpectationFailedException, OAuthCommunicationException, NoSuchAlgorithmException, URISyntaxException {
-        String outcomeServiceUrl = params.get("lisOutcomeServiceURL").asText();
-        String sourcedID = params.get("lisResultSourcedID").asText();
-        String oauthConsumerKey = params.get("oauthConsumerKey").asText();
-
-        String resourceID = work.get("assignmentID").asText();
-        String assignmentID = assignmentOfResource(resourceID); 
-        
-        ObjectNode assignmentNode = s3conn.readJsonObjectFromDynamoDB("CodeCheckAssignments", "assignmentID", assignmentID);
-        double score = Assignment.score(assignmentNode, work);
-        result.put("score", score);     
-        
-        String outcome = lti.passbackGradeToLMS(outcomeServiceUrl, sourcedID, score, oauthConsumerKey); 
-        // org.imsglobal.pox.IMSPOXRequest.sendReplaceResult(outcomeServiceUrl, oauthConsumerKey, getSharedSecret(oauthConsumerKey), sourcedId, "" + score);
-        result.put("outcome", outcome);
+        } 
+    	catch (ServiceException ex) {
+    		return badRequest(ex.getMessage());
+    	}
+        catch (Exception ex) {
+            logger.log(Logger.Level.ERROR, Util.getStackTrace(ex));
+            return internalServerError(Util.getStackTrace(ex));
+        }    		
     }   
 }
\ No newline at end of file
diff --git a/app/controllers/LTIProblem.java b/app/controllers/LTIProblem.java
index 05c04c28..9fe67059 100644
--- a/app/controllers/LTIProblem.java
+++ b/app/controllers/LTIProblem.java
@@ -1,273 +1,105 @@
 package controllers;
 
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Path;
-import java.security.NoSuchAlgorithmException;
-import java.time.Duration;
-import java.time.Instant;
+import java.lang.System.Logger;
 import java.util.Map;
 import java.util.Optional;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
 import javax.inject.Inject;
 
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.JsonNodeFactory;
 import com.fasterxml.jackson.databind.node.ObjectNode;
-import com.horstmann.codecheck.Problem;
 import com.horstmann.codecheck.Util;
 
-import models.CodeCheck;
-import models.LTI;
-import models.S3Connection;
-import play.Logger;
 import play.mvc.Controller;
 import play.mvc.Http;
 import play.mvc.Result;
+import services.ServiceException;
 
 public class LTIProblem extends Controller {
-    @Inject private S3Connection s3conn;
-    @Inject private LTI lti;
-    @Inject private CodeCheck codeCheck;
+    @Inject private services.LTIProblem problemService;    
+    private static Logger logger = System.getLogger("com.horstmann.codecheck");     
     
-    private static Logger.ALogger logger = Logger.of("com.horstmann.codecheck");
-    
-    private ObjectNode ltiNode(Http.Request request) {
-        Map postParams = request.body().asFormUrlEncoded();
-        if (!lti.validate(request)) throw new IllegalArgumentException("Failed OAuth validation");
-        
-        String userID = com.horstmann.codecheck.Util.getParam(postParams, "user_id");
-        if (com.horstmann.codecheck.Util.isEmpty(userID)) throw new IllegalArgumentException("No user id");
-
-        String toolConsumerID = com.horstmann.codecheck.Util.getParam(postParams, "tool_consumer_instance_guid");
-        String contextID = com.horstmann.codecheck.Util.getParam(postParams, "context_id");
-        String resourceLinkID = com.horstmann.codecheck.Util.getParam(postParams, "resource_link_id");
-
-        String resourceID = toolConsumerID + "/" + contextID + "/" + resourceLinkID; 
-        
-
-        ObjectNode ltiNode = JsonNodeFactory.instance.objectNode();
-
-        ltiNode.put("lis_outcome_service_url", com.horstmann.codecheck.Util.getParam(postParams, "lis_outcome_service_url"));       
-        ltiNode.put("lis_result_sourcedid", com.horstmann.codecheck.Util.getParam(postParams, "lis_result_sourcedid"));     
-        ltiNode.put("oauth_consumer_key", com.horstmann.codecheck.Util.getParam(postParams, "oauth_consumer_key"));     
-
-        ltiNode.put("submissionID", resourceID + " " + userID);     
-        ltiNode.put("retrieveURL", "/lti/retrieve");
-        ltiNode.put("sendURL", "/lti/send");
-        
-        return ltiNode;
-    }
-    
-    private String rewriteRelativeLinks(String urlString) throws IOException {
-        URL url = new URL(urlString);
-        InputStream in = url.openStream();
-        String contents = new String(in.readAllBytes(), StandardCharsets.UTF_8);
-        in.close();
-        int i1 = urlString.indexOf("/", 8); // after https://
-        String domain = urlString.substring(0, i1);
-        
-        Pattern pattern = Pattern.compile("\\s+(src|href)=[\"']([^\"']+)[\"']");
-        Matcher matcher = pattern.matcher(contents);
-        int previousEnd = 0;
-        String document = "";
-        while (matcher.find()) {
-            int start = matcher.start();
-            document += contents.substring(previousEnd, start);
-            String group1 = matcher.group(1);
-            String group2 = matcher.group(2);
-            document += " " + group1 + "='";
-            if (group2.startsWith("http:") || group2.startsWith("https:") || group2.startsWith("data:"))
-                document += group2;
-            else if (group2.startsWith("/"))
-                document += domain + "/" + group2;
-            else if (group2.equals("assets/receiveMessage.js")){ // TODO: Hack?
-                document += "/" + group2;
-            } else {
-                int i = urlString.lastIndexOf("/");
-                document += urlString.substring(0, i + 1) + group2;
-            }               
-            document += "'";
-            previousEnd = matcher.end();
-        }           
-        document += contents.substring(previousEnd);        
-        return document;
-    }
-
-    public Result launch(Http.Request request) throws IOException {    
+    public Result launch(Http.Request request) {    
         try {
-            ObjectNode ltiNode = ltiNode(request);
-            
+	        String url = controllers.Util.prefix(request) + request.uri();
             String qid = request.queryString("qid").orElse(null);
-            // TODO: What about CodeCheck qids?
-            if (qid == null) return badRequest("No qid");
-            String domain = "https://www.interactivities.ws";
-            String urlString = domain + "/" + qid + ".xhtml";
-            String document = rewriteRelativeLinks(urlString);
-            document = document.replace("", "");
-            return ok(document).as("text/html");
-        } catch (Exception ex) {
-            logger.error("launch: Cannot load problem " + request + " " + ex.getMessage());
-            return badRequest(ex.getMessage());
-        }
+	        Map postParams = request.body().asFormUrlEncoded();        
+	        String result = problemService.launch(url, qid, postParams);
+            return ok(result).as("text/html");
+	    }
+		catch (ServiceException ex) {
+			return badRequest(ex.getMessage());
+		}
+	    catch (Exception ex) {
+	        logger.log(Logger.Level.ERROR, Util.getStackTrace(ex));
+	        return internalServerError(Util.getStackTrace(ex));
+	    }	        
     }       
     
     public Result launchCodeCheck(Http.Request request, String repo, String problemName) {
         try {           
-            // TODO: Now the client will do the LTI communication. CodeCheck should do it.
-            ObjectNode ltiNode = ltiNode(request);
+	        String url = controllers.Util.prefix(request) + request.uri();
+	        Map postParams = request.body().asFormUrlEncoded();        
             Optional ccidCookie = request.getCookie("ccid");
             String ccid = ccidCookie.map(Http.Cookie::value).orElse(com.horstmann.codecheck.Util.createPronouncableUID());
-            
-            Map problemFiles = codeCheck.loadProblem(repo, problemName, ccid);
-
-            Problem problem = new Problem(problemFiles);
-            Problem.DisplayData data = problem.getProblemData();
-            ObjectNode problemNode = models.Util.toJson(data);
-            problemNode.put("url", "/checkNJS"); 
-            problemNode.put("repo", repo);
-            problemNode.put("problem", problemName);
-            problemNode.remove("description"); // TODO: Or let node render it? 
-            String qid = "codecheck-" + repo + "-" + problemName;
-            String document = "\n" + 
-                "\n" + 
-                "  \n" + 
-                "    \n" + 
-                "    Interactivities \n" + 
-                "    \n" + 
-                "    \n" +
-                "    \n" +
-                "    \n" +
-                "    \n" +
-                "    \n" +
-                "    \n" + 
-                "    \n" + 
-                "    \n" + 
-                "    \n" + 
-                "   \n" + 
-                "  \n" + 
-                "    

Submission ID: " + ltiNode.get("submissionID").asText() + "

" + - "
    \n" + - "
  1. \n" + - "
    \n" + - (data.description == null ? "" : data.description) + - "
    \n" + - "
    \n" + - "
    \n" + - "
  2. \n" + - "
" + - " " + - ""; - Http.Cookie newCookie = Http.Cookie.builder("ccid", ccid).withMaxAge(Duration.ofDays(180)).build(); - return ok(document).withCookies(newCookie).as("text/html"); - } catch (Exception ex) { - logger.error("launchCodeCheck: Cannot load problem " + repo + "/" + problemName + " " + ex.getMessage()); - return badRequest(ex.getMessage()); + Http.Cookie newCookie = controllers.Util.buildCookie("ccid", ccid); + String result = problemService.launchCodeCheck(url, repo, problemName, ccid, postParams); + return ok(result).withCookies(newCookie).as("text/html"); } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } + catch (Exception ex) { + logger.log(Logger.Level.ERROR, Util.getStackTrace(ex)); + return internalServerError(Util.getStackTrace(ex)); + } } - private static String tracerStart = "\n" - + "\n" - + "\n" - + " \n" - + " " - + " CodeCheck Tracer\n" - + " \n" - + "\n" - + "\n"; - private static String tracerScriptStart = "
\n" - + " \n" - + "
\n" - + "\n" - + ""; - public Result launchTracer(Http.Request request, String repo, String problemName) { try { - ObjectNode ltiNode = ltiNode(request); + String url = controllers.Util.prefix(request) + request.uri(); + Map postParams = request.body().asFormUrlEncoded(); Optional ccidCookie = request.getCookie("ccid"); String ccid = ccidCookie.map(Http.Cookie::value).orElse(com.horstmann.codecheck.Util.createPronouncableUID()); - - Map problemFiles = codeCheck.loadProblem(repo, problemName, ccid); - - Problem problem = new Problem(problemFiles); - StringBuilder result = new StringBuilder(); - Problem.DisplayData data = problem.getProblemData(); - result.append(tracerStart); - result.append("

Submission ID: " + ltiNode.get("submissionID").asText() + "

"); - if (data.description != null) - result.append(data.description); - result.append(tracerScriptStart); - result.append("horstmann_config.lti = " + ltiNode.toString() + "\n"); - result.append(Util.getString(problemFiles, Path.of("tracer.js"))); - result.append(tracerEnd); - Http.Cookie newCookie = Http.Cookie.builder("ccid", ccid).withMaxAge(Duration.ofDays(180)).build(); - return ok(result.toString()).withCookies(newCookie).as("text/html"); - } catch (Exception ex) { - logger.error("launchTracer: Cannot load problem " + repo + "/" + problemName + " " + ex.getMessage()); - return badRequest(ex.getMessage()); - } + Http.Cookie newCookie = controllers.Util.buildCookie("ccid", ccid); + String result = problemService.launchTracer(url, repo, problemName, ccid, postParams); + return ok(result).withCookies(newCookie).as("text/html"); + } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } + catch (Exception ex) { + logger.log(Logger.Level.ERROR, Util.getStackTrace(ex)); + return internalServerError(Util.getStackTrace(ex)); + } } - public Result send(Http.Request request) throws IOException, NoSuchAlgorithmException { - ObjectNode requestNode = (ObjectNode) request.body().asJson(); - String submissionID = requestNode.get("submissionID").asText(); - try { - String submittedAt = Instant.now().toString(); - ObjectNode submissionNode = JsonNodeFactory.instance.objectNode(); - submissionNode.put("submissionID", submissionID); - submissionNode.put("submittedAt", submittedAt); - submissionNode.put("state", requestNode.get("state").toString()); - double score = requestNode.get("score").asDouble(); - submissionNode.put("score", score); - s3conn.writeJsonObjectToDynamoDB("CodeCheckSubmissions", submissionNode); - - String outcomeServiceUrl = requestNode.get("lis_outcome_service_url").asText(); - String sourcedID = requestNode.get("lis_result_sourcedid").asText(); - String oauthConsumerKey = requestNode.get("oauth_consumer_key").asText(); - lti.passbackGradeToLMS(outcomeServiceUrl, sourcedID, score, oauthConsumerKey); - - ObjectNode resultNode = JsonNodeFactory.instance.objectNode(); - resultNode.put("score", score); - resultNode.put("submittedAt", submittedAt); - return ok(resultNode); - } catch (Exception e) { - logger.error("send: Cannot send submission " + submissionID + " " + e.getMessage()); - return badRequest(e.getMessage()); + public Result send(Http.Request request) { + try { + ObjectNode requestNode = (ObjectNode) request.body().asJson(); + ObjectNode result = problemService.send(requestNode); + return ok(result); } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } + catch (Exception ex) { + logger.log(Logger.Level.ERROR, Util.getStackTrace(ex)); + return internalServerError(Util.getStackTrace(ex)); + } } - public Result retrieve(Http.Request request) throws IOException { - ObjectNode requestNode = (ObjectNode) request.body().asJson(); - String submissionID = requestNode.get("submissionID").asText(); - try { - ObjectNode result = s3conn.readNewestJsonObjectFromDynamoDB("CodeCheckSubmissions", "submissionID", submissionID); - ObjectMapper mapper = new ObjectMapper(); - result.set("state", mapper.readTree(result.get("state").asText())); + public Result retrieve(Http.Request request) { + try { + ObjectNode requestNode = (ObjectNode) request.body().asJson(); + ObjectNode result = problemService.retrieve(requestNode); return ok(result); - } catch (Exception e) { - logger.error("retrieve: Cannot retrieve submission " + submissionID + " " + e.getMessage()); - return badRequest("retrieve: Cannot retrieve submission " + submissionID); } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } + catch (Exception ex) { + logger.log(Logger.Level.ERROR, Util.getStackTrace(ex)); + return internalServerError(Util.getStackTrace(ex)); + } } } \ No newline at end of file diff --git a/app/controllers/Upload.java b/app/controllers/Upload.java index 272fe895..ca5baf46 100644 --- a/app/controllers/Upload.java +++ b/app/controllers/Upload.java @@ -4,244 +4,106 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Base64; import java.util.Map; import java.util.TreeMap; import javax.inject.Inject; import javax.script.ScriptException; -import com.horstmann.codecheck.Problem; -import com.horstmann.codecheck.Report; -import com.horstmann.codecheck.Util; -import com.typesafe.config.Config; - -import models.CodeCheck; -import models.S3Connection; import play.libs.Files.TemporaryFile; import play.mvc.Controller; import play.mvc.Http; import play.mvc.Result; +import services.ServiceException; public class Upload extends Controller { - final String repo = "ext"; - @Inject private S3Connection s3conn; - @Inject private Config config; - @Inject private CodeCheck codeCheck; - - public Result uploadFiles(Http.Request request) { - return uploadFiles(request, com.horstmann.codecheck.Util.createPublicUID(), Util.createPrivateUID()); - } + @Inject private services.Upload uploadService; - public Result editedFiles(Http.Request request, String problem, String editKey) { - try { - if (checkEditKey(problem, editKey)) - return uploadFiles(request, problem, editKey); - else - return badRequest("Wrong edit key " + editKey + " in problem " + problem); - } catch (IOException ex) { - return badRequest("Problem not found: " + problem); - } catch (Exception ex) { - return internalServerError(Util.getStackTrace(ex)); - } + /** + * Upload a new problem as a form + * @throws ScriptException + * @throws InterruptedException + * @throws IOException + * @throws NoSuchMethodException + */ + public Result uploadFiles(Http.Request request) throws NoSuchMethodException, IOException, InterruptedException, ScriptException { + return editedFiles(request, null, null); } - public Result uploadFiles(Http.Request request, String problem, String editKey) { + /** + * Upload fixes to an existing problem as a form (or new if problem == null) + * @throws ScriptException + * @throws InterruptedException + * @throws IOException + * @throws NoSuchMethodException + */ + public Result editedFiles(Http.Request request, String problem, String editKey) throws NoSuchMethodException, IOException, InterruptedException, ScriptException { try { - if (problem == null) - badRequest("No problem id"); int n = 1; Map params = request.body().asFormUrlEncoded(); Map problemFiles = new TreeMap<>(); while (params.containsKey("filename" + n)) { String filename = params.get("filename" + n)[0]; if (filename.trim().length() > 0) { - String contents = params.get("contents" + n)[0]; + String contents = params.get("contents" + n)[0].replaceAll("\r\n", "\n"); problemFiles.put(Path.of(filename), contents.getBytes(StandardCharsets.UTF_8)); } n++; } - problemFiles.put(Path.of("edit.key"), editKey.getBytes(StandardCharsets.UTF_8)); - saveProblem(problem, problemFiles); - String response = checkProblem(request, problem, problemFiles); - return ok(response).as("text/html").addingToSession(request, "pid", problem); - } catch (Exception ex) { - return internalServerError(Util.getStackTrace(ex)); + String response = uploadService.checkAndSaveProblem(controllers.Util.prefix(request), problem, problemFiles, editKey); + return ok(response).as("text/html"); } - } - - private void saveProblem(String problem, Map problemFiles) throws IOException { - boolean isOnS3 = s3conn.isOnS3("ext"); - if (isOnS3) { - byte[] problemZip = Util.zip(problemFiles); - s3conn.putToS3(problemZip, repo, problem); - } else { - Path extDir = java.nio.file.Path.of(config.getString("com.horstmann.codecheck.repo.ext")); - Path problemDir = extDir.resolve(problem); - com.horstmann.codecheck.Util.deleteDirectory(problemDir); // Delete any prior contents so that it is replaced by new zip file - Files.createDirectories(problemDir); - - for (Map.Entry entry : problemFiles.entrySet()) { - Path p = problemDir.resolve(entry.getKey()); - Files.write(p, entry.getValue()); - } - } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } } - public Result uploadProblem(Http.Request request) { - return uploadProblem(request, com.horstmann.codecheck.Util.createPublicUID(), Util.createPrivateUID()); - } - - private boolean checkEditKey(String problem, String editKey) throws IOException { - Map problemFiles = codeCheck.loadProblem(repo, problem); - Path editKeyPath = Path.of("edit.key"); - if (problemFiles.containsKey(editKeyPath)) { - String correctEditKey = new String(problemFiles.get(editKeyPath), StandardCharsets.UTF_8); - return editKey.equals(correctEditKey.trim()); - } else return false; + /** + * Upload a new problem as a zip file + * @throws ScriptException + * @throws InterruptedException + * @throws IOException + * @throws NoSuchMethodException + */ + public Result uploadProblem(Http.Request request) throws NoSuchMethodException, IOException, InterruptedException, ScriptException { + return editedProblem(request, null, null); } /** * Upload of zip file when editing problem with edit key + * @throws ScriptException + * @throws InterruptedException + * @throws IOException + * @throws NoSuchMethodException */ - public Result editedProblem(Http.Request request, String problem, String editKey) { - try { - if (checkEditKey(problem, editKey)) - return uploadProblem(request, problem, editKey); - else - return badRequest("Wrong edit key " + editKey + " of problem " + problem); - } catch (IOException ex) { - return badRequest("Problem not found: " + problem); - } catch (Exception ex) { - return internalServerError(Util.getStackTrace(ex)); - } - } - - public Result uploadProblem(Http.Request request, String problem, String editKey) { + public Result editedProblem(Http.Request request, String problem, String editKey) throws NoSuchMethodException, IOException, InterruptedException, ScriptException { try { play.mvc.Http.MultipartFormData body = request.body().asMultipartFormData(); - if (problem == null) - badRequest("No problem id"); Http.MultipartFormData.FilePart tempZipPart = body.getFile("file"); TemporaryFile tempZipFile = tempZipPart.getRef(); Path savedPath = tempZipFile.path(); - byte[] contents = Files.readAllBytes(savedPath); - Map problemFiles = Util.unzip(contents); - problemFiles = fixZip(problemFiles); - Path editKeyPath = Path.of("edit.key"); - if (!problemFiles.containsKey(editKeyPath)) - problemFiles.put(editKeyPath, editKey.getBytes(StandardCharsets.UTF_8)); - saveProblem(problem, problemFiles); - String response = checkProblem(request, problem, problemFiles); - return ok(response).as("text/html").addingToSession(request, "pid", problem); - } catch (Exception ex) { - return internalServerError(Util.getStackTrace(ex)); + byte[] problemZip = Files.readAllBytes(savedPath); + String response = uploadService.checkAndSaveProblem(controllers.Util.prefix(request), problem, problemZip, editKey); + return ok(response).as("text/html"); } + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } } - private String checkProblem(Http.Request request, String problem, Map problemFiles) - throws IOException, InterruptedException, NoSuchMethodException, ScriptException { - Map newProblemFiles = new TreeMap<>(problemFiles); - StringBuilder response = new StringBuilder(); - String type; - String report = null; - if (problemFiles.containsKey(Path.of("tracer.js"))) { - type = "tracer"; - } else { - type = "files"; - String studentId = com.horstmann.codecheck.Util.createPronouncableUID(); - codeCheck.replaceParametersInDirectory(studentId, newProblemFiles); - report = check(problem, newProblemFiles, studentId); - } - response.append( - ""); - response.append(""); - String prefix = (request.secure() ? "https://" : "http://") + request.host() + "/"; - String problemUrl = prefix + type + "/" + problem; - response.append("Public URL (for your students): "); - response.append("" + problemUrl + ""); - Path editKeyPath = Path.of("edit.key"); - if (problemFiles.containsKey(editKeyPath)) { - String editKey = new String(problemFiles.get(editKeyPath), StandardCharsets.UTF_8); - String editURL = prefix + "private/problem/" + problem + "/" + editKey; - response.append("
Edit URL (for you only): "); - response.append("" + editURL + ""); - } - if (report != null) { - String run = Base64.getEncoder().encodeToString(report.getBytes(StandardCharsets.UTF_8)); - response.append( - "
"); - } - response.append("\n"); - response.append("

\n"); - return response.toString(); - } - - public Result editKeySubmit(Http.Request request, String problem, String editKey) { + /** + * Get a form for editing the files of an existing problem + * @throws IOException + */ + public Result editProblem(Http.Request request, String problem, String editKey) throws IOException { if (problem.equals("")) return badRequest("No problem id"); try { - Map problemFiles = codeCheck.loadProblem(repo, problem); - Path editKeyPath = Path.of("edit.key"); - if (!problemFiles.containsKey(editKeyPath)) - return badRequest("Wrong edit key " + editKey + " for problem " + problem); - String correctEditKey = new String(problemFiles.get(editKeyPath), StandardCharsets.UTF_8); - if (!editKey.equals(correctEditKey.trim())) { - return badRequest("Wrong edit key " + editKey + " for problem " + problem); - } - Map filesAndContents = new TreeMap<>(); - for (Map.Entry entries : problemFiles.entrySet()) { - Path p = entries.getKey(); - if (p.getNameCount() == 1) - filesAndContents.put(p.toString(), new String(entries.getValue(), StandardCharsets.UTF_8)); - else - return badRequest("Cannot edit problem with directories"); - } - filesAndContents.remove("edit.key"); - return ok(views.html.edit.render(problem, filesAndContents, correctEditKey)); - } catch (IOException ex) { - return badRequest("Problem not found: " + problem); - } catch (Exception ex) { - return internalServerError(Util.getStackTrace(ex)); + String response = uploadService.editProblem(controllers.Util.prefix(request), problem, editKey); + return ok(response).as("text/html"); } - } - - private static Path longestCommonPrefix(Path p, Path q) { - if (p == null || q == null) return null; - int i = 0; - boolean matching = true; - while (matching && i < Math.min(p.getNameCount(), q.getNameCount())) { - if (p.getName(i).equals(q.getName(i))) i++; - else matching = false; - } - return i == 0 ? null : p.subpath(0, i); - } - - private static Map fixZip(Map problemFiles) throws IOException { - Path r = null; - boolean first = true; - for (Path p : problemFiles.keySet()) { - if (first) { r = p; first = false; } - else r = longestCommonPrefix(r, p); - } - if (r == null) return problemFiles; - Map fixedProblemFiles = new TreeMap<>(); - for (Map.Entry entry : problemFiles.entrySet()) { - fixedProblemFiles.put(r.relativize(entry.getKey()), entry.getValue()); - } - return fixedProblemFiles; - } - - private String check(String problem, Map problemFiles, String studentId) - throws IOException, InterruptedException, NoSuchMethodException, ScriptException { - Problem p = new Problem(problemFiles); - Map submissionFiles = new TreeMap<>(); - for (Map.Entry entry : p.getSolutionFiles().entrySet()) - submissionFiles.put(entry.getKey(), new String(entry.getValue(), StandardCharsets.UTF_8)); - for (Map.Entry entry : p.getInputFiles().entrySet()) - submissionFiles.put(entry.getKey(), new String(entry.getValue(), StandardCharsets.UTF_8)); - Report report = codeCheck.run("html", repo, problem, studentId, submissionFiles); - return report.getText(); + catch (ServiceException ex) { + return badRequest(ex.getMessage()); + } } } diff --git a/app/controllers/Util.java b/app/controllers/Util.java new file mode 100644 index 00000000..55afb227 --- /dev/null +++ b/app/controllers/Util.java @@ -0,0 +1,38 @@ +package controllers; + +import play.mvc.Http; + +import java.time.Duration; + +public class Util { + public static String prefix(Http.Request request) { + boolean secure = request.secure() || request.getHeaders().getAll("X-Forwarded-Proto").contains("https"); + /* + One shouldn't have to do this, but with Google Cloud, X-Forwarded-For has two entries (e.g. [95.90.234.41, 130.211.33.19]) + and X-Forwarded-Proto has one ([https]). From + https://github.com/playframework/playframework/blob/814f0c73f86eb0e85bcae7f2167c73a08fed9fd7/transport/server/play-server/src/main/scala/play/core/server/common/ForwardedHeaderHandler.scala + line 204, Play doesn't conclude that the connection was secure. + */ + String prefix; + if (request.host().equals("localhost")) { + prefix = "../"; + long countSlash = request.uri().chars().filter(ch -> ch == '/').count() - 1; + for (long i = 0; i < countSlash; ++i) { + prefix += "../"; + } + prefix = prefix.substring(0, prefix.length() - 1); + } + else { + prefix = (secure ? "https://" : "http://") + request.host(); + } + return prefix; + } + + public static Http.Cookie buildCookie(String name, String value) { + return Http.Cookie.builder(name, value) + .withPath("/") + .withMaxAge(Duration.ofDays(180)) + .withSameSite(Http.Cookie.SameSite.STRICT) + .build(); + } +} diff --git a/app/models/CodeCheck.java b/app/models/CodeCheck.java index 7a245878..2ac84b45 100644 --- a/app/models/CodeCheck.java +++ b/app/models/CodeCheck.java @@ -6,12 +6,14 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; +import java.lang.System.Logger; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyStore; import java.util.Map; import java.util.Properties; +import java.util.TreeMap; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; @@ -24,36 +26,25 @@ import javax.script.ScriptException; import com.horstmann.codecheck.Main; -import com.horstmann.codecheck.Report; +import com.horstmann.codecheck.Plan; +import com.horstmann.codecheck.Problem; import com.horstmann.codecheck.ResourceLoader; import com.horstmann.codecheck.Util; -import com.typesafe.config.Config; +import controllers.Config; import jdk.security.jarsigner.JarSigner; -import play.Logger; -import play.api.Environment; @Singleton public class CodeCheck { - private static Logger.ALogger logger = Logger.of("com.horstmann.codecheck"); - private Config config; - private S3Connection s3conn; + public static final String DEFAULT_REPO = "ext"; + private static Logger logger = System.getLogger("com.horstmann.codecheck"); + private StorageConnector storeConn; private JarSigner signer; private ResourceLoader resourceLoader; - @Inject public CodeCheck(Config config, S3Connection s3conn, Environment playEnv) { - this.config = config; - this.s3conn = s3conn; - resourceLoader = new ResourceLoader() { - @Override - public InputStream loadResource(String path) throws IOException { - return playEnv.classLoader().getResourceAsStream("public/resources/" + path); - } - @Override - public String getProperty(String key) { - return config.hasPath(key) ? config.getString(key) : null; - } - }; + @Inject public CodeCheck(Config config, StorageConnector storeConn) { + this.storeConn = storeConn; + resourceLoader = config; try { String keyStorePath = config.getString("com.horstmann.codecheck.storeLocation"); char[] password = config.getString("com.horstmann.codecheck.storePassword").toCharArray(); @@ -62,10 +53,9 @@ public String getProperty(String key) { KeyStore.PrivateKeyEntry pkEntry = (KeyStore.PrivateKeyEntry) ks.getEntry("codecheck", protParam); signer = new JarSigner.Builder(pkEntry).build(); } catch (Exception e) { - logger.warn("Cannot load keystore", e); + logger.log(Logger.Level.WARNING, "Cannot load keystore"); } } - public Map loadProblem(String repo, String problemName, String studentId) throws IOException, ScriptException, NoSuchMethodException { Map problemFiles = loadProblem(repo, problemName); @@ -73,7 +63,13 @@ public Map loadProblem(String repo, String problemName, String stu return problemFiles; } - public void replaceParametersInDirectory(String studentId, Map problemFiles) + /** + * + * @param studentId the seed for the random number generator + * @param problemFiles the problem files to rewrite + * @return true if this is a parametric problem + */ + public boolean replaceParametersInDirectory(String studentId, Map problemFiles) throws ScriptException, NoSuchMethodException, IOException { Path paramPath = Path.of("param.js"); if (problemFiles.containsKey(paramPath)) { @@ -84,13 +80,15 @@ public void replaceParametersInDirectory(String studentId, Map pro //seeding unique student id ((Invocable) engine).invokeMethod(engine.get("Math"), "seedrandom", studentId); engine.eval(Util.getString(problemFiles, paramPath)); - for (Path p : Util.filterNot(problemFiles.keySet(), "*.jar", "*.gif", "*.png", "*.jpg", "*.wav")) { + for (Path p : Util.filterNot(problemFiles.keySet(), "param.js", "*.jar", "*.gif", "*.png", "*.jpg", "*.wav")) { String contents = new String(problemFiles.get(p), StandardCharsets.UTF_8); String result = replaceParametersInFile(contents, engine); if (result != null) problemFiles.put(p, result.getBytes(StandardCharsets.UTF_8)); } + return true; } + else return false; } private String replaceParametersInFile(String contents, ScriptEngine engine) throws ScriptException, IOException { @@ -128,29 +126,77 @@ private String replaceParametersInFile(String contents, ScriptEngine engine) thr } public Map loadProblem(String repo, String problemName) throws IOException { - if (s3conn.isOnS3(repo)) { - return Util.unzip(s3conn.readFromS3(repo, problemName)); - } else { - Path repoPath = Path.of(config.getString("com.horstmann.codecheck.repo." - + repo)); - // TODO: That comes from Problems.java--fix it there - if (problemName.startsWith("/")) - problemName = problemName.substring(1); - return Util.descendantFiles(repoPath.resolve(problemName)); - } + Map result; + byte[] zipFile = storeConn.readProblem(repo, problemName); + result = Util.unzip(zipFile); + return result; } - - public Report run(String reportType, String repo, + + public void saveProblem(String repo, String problem, Map problemFiles) throws IOException { + byte[] problemZip = Util.zip(problemFiles); + storeConn.writeProblem(problemZip, repo, problem); + } + + public String run(String reportType, String repo, String problem, String ccid, Map submissionFiles) throws IOException, InterruptedException, NoSuchMethodException, ScriptException { Map problemFiles = loadProblem(repo, problem, ccid); + // Save solution outputs if not parametric and doesn't have already have solution output + boolean save = !problemFiles.containsKey(Path.of("param.js")) && + !problemFiles.keySet().stream().anyMatch(p -> p.startsWith("_outputs")); Properties metaData = new Properties(); metaData.put("User", ccid); metaData.put("Problem", (repo + "/" + problem).replaceAll("[^\\pL\\pN_/-]", "")); - return new Main().run(submissionFiles, problemFiles, reportType, metaData, resourceLoader); + Plan plan = new Main().run(submissionFiles, problemFiles, reportType, metaData, resourceLoader); + if (save) { + plan.writeSolutionOutputs(problemFiles); + saveProblem(repo, problem, problemFiles); + } + + return plan.getReport().getText(); } + /** + * Run files with given input + * @return the report of the run + */ + public String run(String reportType, Map submissionFiles) + throws IOException, InterruptedException, NoSuchMethodException, ScriptException { + Map problemFiles = new TreeMap<>(); + for (var entry : submissionFiles.entrySet()) { + var key = entry.getKey(); + problemFiles.put(key, entry.getValue().getBytes()); + } + Properties metaData = new Properties(); + Plan plan = new Main().run(submissionFiles, problemFiles, reportType, metaData, resourceLoader); + return plan.getReport().getText(); + } + + /** + Runs CodeCheck for checking a problem submission. + Saves the problem and the precomputed solution runs. + */ + public String checkAndSave(String problem, Map originalProblemFiles) + throws IOException, InterruptedException, NoSuchMethodException, ScriptException { + Map problemFiles = new TreeMap<>(originalProblemFiles); + String studentId = com.horstmann.codecheck.Util.createPronouncableUID(); + boolean isParametric = replaceParametersInDirectory(studentId, problemFiles); + + Problem p = new Problem(problemFiles); + Map submissionFiles = new TreeMap<>(); + for (Map.Entry entry : p.getSolutionFiles().entrySet()) + submissionFiles.put(entry.getKey(), new String(entry.getValue(), StandardCharsets.UTF_8)); + for (Map.Entry entry : p.getInputFiles().entrySet()) + submissionFiles.put(entry.getKey(), new String(entry.getValue(), StandardCharsets.UTF_8)); + + Properties metaData = new Properties(); + Plan plan = new Main().run(submissionFiles, problemFiles, "html", metaData, resourceLoader); + if (!isParametric) + plan.writeSolutionOutputs(problemFiles); + saveProblem(DEFAULT_REPO, problem, originalProblemFiles); + return plan.getReport().getText(); + } public byte[] signZip(Map contents) throws IOException { if (signer == null) return Util.zip(contents); diff --git a/app/models/JWT.java b/app/models/JWT.java index 488dd128..9f7aaf49 100644 --- a/app/models/JWT.java +++ b/app/models/JWT.java @@ -1,5 +1,6 @@ package models; +import java.time.Duration; import java.time.Instant; import java.util.Date; import java.util.Map; @@ -24,7 +25,7 @@ public String generate(Map claims) { .setIssuer("codecheck.io") .addClaims(claims) .setIssuedAt(Date.from(Instant.now())) - .setExpiration(Date.from(Instant.now().plusSeconds(60 * 60 * 3))) + .setExpiration(Date.from(Instant.now().plus(Duration.ofDays(7)))) .signWith(SignatureAlgorithm.HS256, key) .compact(); return jwt; diff --git a/app/models/LTI.java b/app/models/LTI.java index 02b9e6b0..b57067be 100644 --- a/app/models/LTI.java +++ b/app/models/LTI.java @@ -3,6 +3,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.lang.System.Logger; +import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.net.URLEncoder; @@ -21,8 +23,6 @@ import javax.inject.Singleton; import javax.net.ssl.HttpsURLConnection; -import com.fasterxml.jackson.databind.node.ObjectNode; - import net.oauth.OAuthAccessor; import net.oauth.OAuthConsumer; import net.oauth.OAuthMessage; @@ -33,24 +33,21 @@ import oauth.signpost.exception.OAuthExpectationFailedException; import oauth.signpost.exception.OAuthMessageSignerException; import oauth.signpost.http.HttpParameters; -import play.Logger; -import play.mvc.Http; + @Singleton public class LTI { - @Inject private S3Connection s3conn; - private static Logger.ALogger logger = Logger.of("com.horstmann.codecheck"); + @Inject private StorageConnector assignmentConn; + private static Logger logger = System.getLogger("com.horstmann.codecheck"); - public boolean validate(Http.Request request) { + public boolean validate(String url, Map postParams) { final String OAUTH_KEY_PARAMETER = "oauth_consumer_key"; - - Map postParams = request.body().asFormUrlEncoded(); + if (postParams == null) return false; Set> entries = new HashSet<>(); for (Map.Entry entry : postParams.entrySet()) for (String s : entry.getValue()) entries.add(new AbstractMap.SimpleEntry<>(entry.getKey(), s)); - String url = Util.prefix(request) + request.uri(); String key = com.horstmann.codecheck.Util.getParam(postParams, OAUTH_KEY_PARAMETER); for (Map.Entry entry : com.horstmann.codecheck.Util.getParams(url).entrySet()) @@ -66,20 +63,19 @@ public boolean validate(Http.Request request) { oav.validateMessage(oam, acc); return true; } catch (Exception e) { - logger.error("Did not validate: " + e.getLocalizedMessage() + "\nurl: " + url + "\nentries: " + entries); + logger.log(Logger.Level.ERROR, "Did not validate: " + e.getLocalizedMessage() + "\nurl: " + url + "\nentries: " + entries); return false; } } - // TODO Move this so that LTI doesn't depend on S3, Play - public String getSharedSecret(String oauthConsumerKey) { + private String getSharedSecret(String oauthConsumerKey) { String sharedSecret = ""; try { - ObjectNode result = s3conn.readJsonObjectFromDynamoDB("CodeCheckLTICredentials", "oauth_consumer_key", oauthConsumerKey); - if (result != null) sharedSecret = result.get("shared_secret").asText(); - else logger.warn("No shared secret for consumer key " + oauthConsumerKey); + sharedSecret = assignmentConn.readLTISharedSecret(oauthConsumerKey); + if (sharedSecret == null) + logger.log(Logger.Level.WARNING, "No shared secret for consumer key " + oauthConsumerKey); } catch (IOException e) { - logger.warn("Could not read CodeCheckLTICredentials"); + logger.log(Logger.Level.WARNING, "Could not read CodeCheckLTICredentials"); // Return empty string } return sharedSecret; @@ -100,7 +96,7 @@ public String passbackGradeToLMS(String gradePassbackURL, String xmlString3 = " "; String xml = xmlString1 + sourcedID + xmlString2 + score + xmlString3; - URL url = new URL(gradePassbackURL); + URL url = URI.create(gradePassbackURL).toURL(); HttpsURLConnection request = (HttpsURLConnection) url.openConnection(); request.setRequestMethod("POST"); request.setRequestProperty("Content-Type", "application/xml"); @@ -120,8 +116,7 @@ public String passbackGradeToLMS(String gradePassbackURL, consumer.setAdditionalParameters(params); consumer.sign(request); - //logger.info("Request after signing: {}", consumer.getRequestParameters()); - //logger.info("XML: {}", xml); + // logger.info("passbackGradeToLMS: URL {}, request {}, XML {}", url, new java.util.TreeMap<>(consumer.getRequestParameters()), xml); // POST the xml to the grade passback url request.setDoOutput(true); @@ -130,7 +125,7 @@ public String passbackGradeToLMS(String gradePassbackURL, out.close(); // request.connect(); if (request.getResponseCode() != 200) - logger.warn("passbackGradeToLMS: Not successful" + request.getResponseCode() + " " + request.getResponseMessage()); + logger.log(Logger.Level.WARNING, "passbackGradeToLMS: Not successful" + request.getResponseCode() + " " + request.getResponseMessage()); try { InputStream in = request.getInputStream(); String body = new String(in.readAllBytes(), StandardCharsets.UTF_8); @@ -141,12 +136,12 @@ public String passbackGradeToLMS(String gradePassbackURL, if (matcher2.find()) message += ": " + matcher2.group(1); if (message.length() == 0) message = body; if (!body.contains("success")) - logger.warn("passbackGradeToLMS: Not successful " + body); + logger.log(Logger.Level.WARNING, "passbackGradeToLMS: Not successful " + body); return message; } catch (Exception e) { InputStream in = request.getErrorStream(); String body = new String(in.readAllBytes(), StandardCharsets.UTF_8); - logger.warn("passbackGradeToLMS: Response error " + e.getMessage() + ": " + body); + logger.log(Logger.Level.WARNING, "passbackGradeToLMS: Response error " + e.getMessage() + ": " + body); return body; } } diff --git a/app/models/S3Connection.java b/app/models/S3Connection.java deleted file mode 100644 index 5bb8406f..00000000 --- a/app/models/S3Connection.java +++ /dev/null @@ -1,293 +0,0 @@ -package models; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import com.amazonaws.AmazonServiceException; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; -import com.amazonaws.services.dynamodbv2.document.DynamoDB; -import com.amazonaws.services.dynamodbv2.document.Item; -import com.amazonaws.services.dynamodbv2.document.ItemCollection; -import com.amazonaws.services.dynamodbv2.document.QueryOutcome; -import com.amazonaws.services.dynamodbv2.document.RangeKeyCondition; -import com.amazonaws.services.dynamodbv2.document.Table; -import com.amazonaws.services.dynamodbv2.document.spec.PutItemSpec; -import com.amazonaws.services.dynamodbv2.document.spec.QuerySpec; -import com.amazonaws.services.dynamodbv2.document.utils.ValueMap; -import com.amazonaws.services.dynamodbv2.model.ResourceNotFoundException; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import com.amazonaws.services.s3.model.AmazonS3Exception; -import com.amazonaws.services.s3.model.ListObjectsV2Request; -import com.amazonaws.services.s3.model.ListObjectsV2Result; -import com.amazonaws.services.s3.model.ObjectMetadata; -import com.amazonaws.services.s3.model.S3ObjectSummary; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.typesafe.config.Config; - -import play.Logger; - -@Singleton -public class S3Connection { - private Config config; - private String bucketSuffix = null; - private AmazonS3 amazonS3; - private AmazonDynamoDB amazonDynamoDB; - private static Logger.ALogger logger = Logger.of("com.horstmann.codecheck"); - - @Inject public S3Connection(Config config) { - this.config = config; - // For local testing only--TODO: What exactly should work in this situation? - if (!config.hasPath("com.horstmann.codecheck.s3.accessKey")) return; - - String s3AccessKey = config.getString("com.horstmann.codecheck.s3.accessKey"); - String s3SecretKey = config.getString("com.horstmann.codecheck.s3.secretKey"); - String s3Region = config.getString("com.horstmann.codecheck.s3.region"); - amazonS3 = AmazonS3ClientBuilder - .standard() - .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(s3AccessKey, s3SecretKey))) - .withRegion(s3Region) - .withForceGlobalBucketAccessEnabled(true) - .build(); - - amazonDynamoDB = AmazonDynamoDBClientBuilder - .standard() - .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(s3AccessKey, s3SecretKey))) - .withRegion("us-west-1") - .build(); - - bucketSuffix = config.getString("com.horstmann.codecheck.s3bucketsuffix"); - } - - public boolean isOnS3(String repo) { - String key = "com.horstmann.codecheck.repo." + repo; - return !config.hasPath(key) || config.getString(key).isEmpty(); - } - - public boolean isOnS3(String repo, String key) { - String bucket = repo + "." + bucketSuffix; - return getS3Connection().doesObjectExist(bucket, key); - } - - private AmazonS3 getS3Connection() { - return amazonS3; - } - - public AmazonDynamoDB getAmazonDynamoDB() { - return amazonDynamoDB; - } - - public void putToS3(Path file, String repo, String key) - throws IOException { - String bucket = repo + "." + bucketSuffix; - try { - getS3Connection().putObject(bucket, key, file.toFile()); - } catch (AmazonS3Exception ex) { - logger.error("S3Connection.putToS3: Cannot put " + file + " to " + bucket); - throw ex; - } - } - - public void putToS3(String contents, String repo, String key) - throws IOException { - String bucket = repo + "." + bucketSuffix; - try { - getS3Connection().putObject(bucket, key, contents); - } catch (AmazonS3Exception ex) { - logger.error("S3Connection.putToS3: Cannot put " + contents.replaceAll("\n", "|").substring(0, Math.min(50, contents.length())) + "... to " + bucket); - throw ex; - } - } - - public void putToS3(byte[] contents, String repo, String key) - throws IOException { - String bucket = repo + "." + bucketSuffix; - ObjectMetadata metadata = new ObjectMetadata(); - metadata.setContentLength(contents.length); - metadata.setContentType("application/zip"); - try { - try (ByteArrayInputStream in = new ByteArrayInputStream(contents)) { - getS3Connection().putObject(bucket, key, in, metadata); - } - } catch (AmazonS3Exception ex) { - String bytes = Arrays.toString(contents); - logger.error("S3Connection.putToS3: Cannot put " + bytes.substring(0, Math.min(50, bytes.length())) + "... to " + bucket); - throw ex; - } - } - - public void deleteFromS3(String repo, String key) - throws IOException { - String bucket = repo + "." + bucketSuffix; - try { - getS3Connection().deleteObject(bucket, key); - } catch (AmazonS3Exception ex) { - logger.error("S3Connection.deleteFromS3: Cannot delete " + bucket); - throw ex; - } - } - - public byte[] readFromS3(String repo, String problem) - throws IOException { - String bucket = repo + "." + bucketSuffix; - - byte[] bytes = null; - try { - // TODO -- trying to avoid warning - // WARN - com.amazonaws.services.s3.internal.S3AbortableInputStream - Not all bytes were read from the S3ObjectInputStream, aborting HTTP connection. This is likely an error and may result in sub-optimal behavior. Request only the bytes you need via a ranged GET or drain the input stream after use - try (InputStream in = getS3Connection().getObject(bucket, problem).getObjectContent()) { - bytes = in.readAllBytes(); - } - } catch (AmazonS3Exception ex) { - logger.error("S3Connection.readFromS3: Cannot read " + problem + " from " + bucket); - throw ex; - } - return bytes; - } - - public List readS3keys(String repo, String keyPrefix) throws AmazonServiceException { - // https://docs.aws.amazon.com/AmazonS3/latest/dev/ListingObjectKeysUsingJava.html - String bucket = repo + "." + bucketSuffix; - ListObjectsV2Request req = new ListObjectsV2Request() - .withBucketName(bucket).withMaxKeys(100).withPrefix(keyPrefix); - ListObjectsV2Result result; - List allKeys = new ArrayList(); - - do { - result = getS3Connection().listObjectsV2(req); - - for (S3ObjectSummary objectSummary : result.getObjectSummaries()) { - allKeys.add(objectSummary.getKey()); - } - - String token = result.getNextContinuationToken(); - req.setContinuationToken(token); - } while (result.isTruncated()); - return allKeys; - } - - public ObjectNode readJsonObjectFromDynamoDB(String tableName, String primaryKeyName, String primaryKeyValue) throws IOException { - String result = readJsonStringFromDynamoDB(tableName, primaryKeyName, primaryKeyValue); - return result == null ? null : (ObjectNode)(new ObjectMapper().readTree(result)); - } - - public String readJsonStringFromDynamoDB(String tableName, String primaryKeyName, String primaryKeyValue) throws IOException { - DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); - Table table = dynamoDB.getTable(tableName); - ItemCollection items = table.query(primaryKeyName, primaryKeyValue); - try { - Iterator iterator = items.iterator(); - if (iterator.hasNext()) - return iterator.next().toJSON(); - else - return null; - } catch (ResourceNotFoundException ex) { - return null; - } - } - - public ObjectNode readJsonObjectFromDynamoDB(String tableName, String primaryKeyName, String primaryKeyValue, String sortKeyName, String sortKeyValue) throws IOException { - String result = readJsonStringFromDynamoDB(tableName, primaryKeyName, primaryKeyValue, sortKeyName, sortKeyValue); - return result == null ? null : (ObjectNode)(new ObjectMapper().readTree(result)); - } - - public ObjectNode readNewestJsonObjectFromDynamoDB(String tableName, String primaryKeyName, String primaryKeyValue) { - DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); - Table table = dynamoDB.getTable(tableName); - QuerySpec spec = new QuerySpec() - .withKeyConditionExpression(primaryKeyName + " = :primaryKey" ) - .withValueMap(new ValueMap().withString(":primaryKey", primaryKeyValue)) - .withScanIndexForward(false); - - ItemCollection items = table.query(spec); - try { - Iterator iterator = items.iterator(); - if (iterator.hasNext()) { - String result = iterator.next().toJSON(); - try { - return (ObjectNode)(new ObjectMapper().readTree(result)); - } catch (JsonProcessingException ex) { - return null; - } - } - else - return null; - } catch (ResourceNotFoundException ex) { - return null; - } - } - - public String readJsonStringFromDynamoDB(String tableName, String primaryKeyName, String primaryKeyValue, String sortKeyName, String sortKeyValue) throws IOException { - DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); - Table table = dynamoDB.getTable(tableName); - ItemCollection items = table.query(primaryKeyName, primaryKeyValue, - new RangeKeyCondition(sortKeyName).eq(sortKeyValue)); - try { - Iterator iterator = items.iterator(); - if (iterator.hasNext()) - return iterator.next().toJSON(); - else - return null; - } catch (ResourceNotFoundException ex) { - return null; - } - } - - public Map readJsonObjectsFromDynamoDB(String tableName, String primaryKeyName, String primaryKeyValue, String sortKeyName) throws IOException { - DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); - Table table = dynamoDB.getTable(tableName); - ItemCollection items = table.query(primaryKeyName, primaryKeyValue); - Iterator iterator = items.iterator(); - Map itemMap = new HashMap<>(); - while (iterator.hasNext()) { - Item item = iterator.next(); - String key = item.getString(sortKeyName); - itemMap.put(key, (ObjectNode)(new ObjectMapper().readTree(item.toJSON()))); - } - return itemMap; - } - - public void writeJsonObjectToDynamoDB(String tableName, ObjectNode obj) { - DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); - Table table = dynamoDB.getTable(tableName); - table.putItem( - new PutItemSpec() - .withItem(Item.fromJSON(obj.toString())) - ); - } - - public void writeNewerJsonObjectToDynamoDB(String tableName, ObjectNode obj, String primaryKeyName, String timeStampKeyName) { - DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); - Table table = dynamoDB.getTable(tableName); - /* - To prevent a new item from replacing an existing item, use a conditional expression that contains the attribute_not_exists function with the name of the attribute being used as the partition key for the table. Since every record must contain that attribute, the attribute_not_exists function will only succeed if no matching item exists. - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/SQLtoNoSQL.WriteData.html - - Apparently, the simpler putItem(item, conditionalExpression, nameMap, valueMap) swallows the ConditionalCheckFailedException - */ - String conditionalExpression = "attribute_not_exists(" + primaryKeyName + ") OR " + timeStampKeyName + " < :" + timeStampKeyName; - table.putItem( - new PutItemSpec() - .withItem(Item.fromJSON(obj.toString())) - .withConditionExpression(conditionalExpression) - .withValueMap(Collections.singletonMap(":" + timeStampKeyName, obj.get(timeStampKeyName).asText())) - ); - } -} \ No newline at end of file diff --git a/app/models/StorageConnector.java b/app/models/StorageConnector.java new file mode 100644 index 00000000..c8ba76a3 --- /dev/null +++ b/app/models/StorageConnector.java @@ -0,0 +1,947 @@ +package models; + +import java.io.ByteArrayInputStream; + +/* + +Test plan + +http://localhost:9000/assets/uploadProblem.html + +Test.py + +##HIDE +print('hi') +##EDIT ... + +Save, then edit and modify + +http://localhost:9000/newAssignment +paste URL from problem, save + +visit as student +solve +use private url in another browser +clear id +solve again, different + +visit as instructor +view submissions +click on a submission +make a comment +view again to see if comment saved +view as student to see if comment saved + + */ + + +import java.io.IOException; +import java.io.InputStream; +import java.lang.System.Logger; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.FileLockInterruptionException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; +import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; +import com.amazonaws.services.dynamodbv2.document.DynamoDB; +import com.amazonaws.services.dynamodbv2.document.Item; +import com.amazonaws.services.dynamodbv2.document.ItemCollection; +import com.amazonaws.services.dynamodbv2.document.QueryOutcome; +import com.amazonaws.services.dynamodbv2.document.RangeKeyCondition; +import com.amazonaws.services.dynamodbv2.document.Table; +import com.amazonaws.services.dynamodbv2.document.spec.PutItemSpec; +import com.amazonaws.services.dynamodbv2.document.spec.QuerySpec; +import com.amazonaws.services.dynamodbv2.document.utils.ValueMap; +import com.amazonaws.services.dynamodbv2.model.ConditionalCheckFailedException; +import com.amazonaws.services.dynamodbv2.model.ResourceNotFoundException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.AmazonS3Exception; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.horstmann.codecheck.Util; + +import controllers.Config; + +@Singleton public class StorageConnector { + private StorageConnection delegate; + + @Inject public StorageConnector(Config config) { + String type = "local"; + if (config.hasPath("com.horstmann.codecheck.storage.type")) + type = config.getString("com.horstmann.codecheck.storage.type"); + if (type.equalsIgnoreCase("aws")) + delegate = new AWSStorageConnection(config); + else if (type.equalsIgnoreCase("sql")) { + delegate = new SQLStorageConnection(config); + } + else + delegate = new LocalStorageConnection(config); + } + + public byte[] readProblem(String repo, String key) throws IOException { + return delegate.readProblem(repo, key); + } + + public void writeProblem(byte[] contents, String repo, String key) throws IOException { + delegate.writeProblem(contents, repo, key); + } + + public ObjectNode readAssignment(String assignmentID) throws IOException { + return delegate.readAssignment(assignmentID); + } + + public String readLegacyLTIResource(String resourceID) throws IOException { + return delegate.readLegacyLTIResource(resourceID); + } + + public String readLTISharedSecret(String oauthConsumerKey) throws IOException { + return delegate.readLTISharedSecret(oauthConsumerKey); + } + + public String readComment(String assignmentID, String workID) throws IOException { + return delegate.readComment(assignmentID, workID); + } + + public ObjectNode readWork(String assignmentID, String workID) throws IOException { + return delegate.readWork(assignmentID, workID); + } + + public String readWorkString(String assignmentID, String workID) throws IOException { + return delegate.readWorkString(assignmentID, workID); + } + + public ObjectNode readNewestSubmission(String submissionID) throws IOException { + return delegate.readNewestSubmission(submissionID); + } + + public Map readAllWork(String assignmentID) throws IOException { + return delegate.readAllWork(assignmentID); + } + + public void writeAssignment(JsonNode node) throws IOException { + delegate.writeAssignment(node); + } + + public void writeSubmission(JsonNode node) throws IOException { + delegate.writeSubmission(node); + } + + public void writeComment(JsonNode node) throws IOException { + delegate.writeComment(node); + } + + public boolean writeWork(JsonNode node) throws IOException { + return delegate.writeWork(node); + } +} + +interface StorageConnection { + byte[] readProblem(String repo, String key) throws IOException; + void writeProblem(byte[] contents, String repo, String key) throws IOException; + ObjectNode readAssignment(String assignmentID) throws IOException; + String readLegacyLTIResource(String resourceID) throws IOException; + String readLTISharedSecret(String oauthConsumerKey) throws IOException; + String readComment(String assignmentID, String workID) throws IOException; + ObjectNode readWork(String assignmentID, String workID) throws IOException; + String readWorkString(String assignmentID, String workID) throws IOException; + ObjectNode readNewestSubmission(String submissionID) throws IOException; + Map readAllWork(String assignmentID) throws IOException; + void writeAssignment(JsonNode node) throws IOException; + void writeSubmission(JsonNode node) throws IOException; + void writeComment(JsonNode node) throws IOException; + boolean writeWork(JsonNode node) throws IOException; // return true if this version was saved (because it was newer) +} + +/* + Tables: + + CodeCheckAssignment + assignmentID [partition key] // non-LTI: courseID? + assignmentID, LTI: toolConsumerID/courseID + assignment ID, Legacy tool consumer ID/course ID/resource ID + deadline (an ISO 8601 string like "2020-12-01T23:59:59Z") + editKey // LTI: tool consumer ID + user ID + problems + array of // One per group + array of { URL, qid?, weight } // qid for book repo + + CodeCheckLTIResources (Legacy) + resourceID [primary key] // LTI tool consumer ID + course ID + resource ID + assignmentID + + CodeCheckWork is a map from problem keys to scores and states. It only stores the most recent version. + + CodeCheckWork + assignmentID [primary key] + workID [sort key] // non-LTI: ccid/editKey, LTI: userID + problems + map from URL/qids to { state, score } + submittedAt + tab + + CodeCheckSubmissions is an append-only log of all submissions of a single problem. + + CodeCheckSubmissions + submissionID [partition key] // non-LTI: courseID? + assignmentID + problemKey + ccid/editKey , LTI: toolConsumerID/courseID + assignmentID + problemID + userID + // either way, that's resource ID + workID + problem key + submittedAt [sort key] + state: as string, not JSON + score + + with global secondary index (TODO: Not currently) + problemID + submitterID + + CodeCheckLTICredentials + oauth_consumer_key [primary key] + shared_secret + +CodeCheckComments + assignmentID [partition key] // non-LTI: courseID? + assignmentID, LTI: toolConsumerID/courseID + assignment ID, Legacy tool consumer ID/course ID/resource ID + workID [sort key] // non-LTI: ccid/editKey, LTI: userID + comment + + (This is a separate table from CodeCheckWork because we can't guarantee atomic updates if a student happens to update their work while the instructor updates a comment) + +*/ + +class AWSStorageConnection implements StorageConnection { + private static Logger logger = System.getLogger("com.horstmann.codecheck"); + private String bucketSuffix = null; + private AmazonS3 amazonS3; + private AmazonDynamoDB amazonDynamoDB; + public static class OutOfOrderException extends RuntimeException {} + + public AWSStorageConnection(Config config) { + String awsAccessKey = config.getString("com.horstmann.codecheck.aws.accessKey"); + String awsSecretKey = config.getString("com.horstmann.codecheck.aws.secretKey"); + String s3region = config.getString("com.horstmann.codecheck.s3.region"); + amazonS3 = AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(awsAccessKey, awsSecretKey))) + .withRegion(s3region) + .withForceGlobalBucketAccessEnabled(true) + .build(); + + bucketSuffix = config.getString("com.horstmann.codecheck.s3.bucketsuffix"); + + String dynamoDBregion = config.getString("com.horstmann.codecheck.dynamodb.region"); + + amazonDynamoDB = AmazonDynamoDBClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(awsAccessKey, awsSecretKey))) + .withRegion(dynamoDBregion) + .build(); + } + + public byte[] readProblem(String repo, String key) throws IOException { + String bucket = repo + "." + bucketSuffix; + + byte[] bytes = null; + try { + // TODO -- trying to avoid warning + // WARN - com.amazonaws.services.s3.internal.S3AbortableInputStream - Not all bytes were read from the S3ObjectInputStream, aborting HTTP connection. This is likely an error and may result in sub-optimal behavior. Request only the bytes you need via a ranged GET or drain the input stream after use + try (InputStream in = amazonS3.getObject(bucket, key).getObjectContent()) { + bytes = in.readAllBytes(); + } + } catch (AmazonS3Exception ex) { + logger.log(Logger.Level.ERROR, "S3Connection.readFromS3: Cannot read " + key + " from " + bucket); + throw ex; + } + return bytes; + } + + public void writeProblem(byte[] contents, String repo, String key) throws IOException { + String bucket = repo + "." + bucketSuffix; + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(contents.length); + metadata.setContentType("application/zip"); + try { + try (ByteArrayInputStream in = new ByteArrayInputStream(contents)) { + amazonS3.putObject(bucket, key, in, metadata); + } + } catch (AmazonS3Exception ex) { + String bytes = Arrays.toString(contents); + logger.log(Logger.Level.ERROR, "S3Connection.putToS3: Cannot put " + bytes.substring(0, Math.min(50, bytes.length())) + "... to " + bucket); + throw ex; + } + } + + public ObjectNode readAssignment(String assignmentID) throws IOException { + return readJsonObjectFromDB("CodeCheckAssignments", "assignmentID", assignmentID); + } + + public String readLegacyLTIResource(String resourceID) throws IOException { + ObjectNode node = readJsonObjectFromDB("CodeCheckLTIResources", "resourceID", resourceID); + if (node == null) return null; + else return node.get("assignmentID").asText(); + } + + public String readLTISharedSecret(String oauthConsumerKey) throws IOException { + ObjectNode node = readJsonObjectFromDB("CodeCheckLTICredentials", "oauth_consumer_key", oauthConsumerKey); + if (node == null) return null; + else return node.get("shared_secret").asText(); + } + + public String readComment(String assignmentID, String workID) throws IOException { + ObjectNode node = readJsonObjectFromDB("CodeCheckComments", "assignmentID", assignmentID, "workID", workID); + if (node == null) return ""; + else return node.get("comment").asText(); + } + + public ObjectNode readWork(String assignmentID, String workID) throws IOException { + return readJsonObjectFromDB("CodeCheckWork", "assignmentID", assignmentID, "workID", workID); + } + + public String readWorkString(String assignmentID, String workID) throws IOException { + return readJsonStringFromDB("CodeCheckWork", "assignmentID", assignmentID, "workID", workID); + } + + public ObjectNode readNewestSubmission(String submissionID) throws IOException { + return readNewestJsonObjectFromDB("CodeCheckSubmissions", "submissionID", submissionID); + } + + public Map readAllWork(String assignmentID) throws IOException { + return readJsonObjectsFromDB("CodeCheckWork", "assignmentID", assignmentID, "workID"); + } + + public void writeAssignment(JsonNode node) throws IOException { + writeJsonObjectToDB("CodeCheckAssignments", node); + } + + public void writeSubmission(JsonNode node) throws IOException { + writeJsonObjectToDB("CodeCheckSubmissions", node); + } + + public void writeComment(JsonNode node) throws IOException { + writeJsonObjectToDB("CodeCheckComments", node); + } + + public boolean writeWork(JsonNode node) throws IOException { + return writeNewerJsonObjectToDB("CodeCheckWork", node, "assignmentID", "submittedAt"); + } + + private ObjectNode readJsonObjectFromDB(String tableName, String primaryKeyName, String primaryKeyValue) throws IOException { + String result = readJsonStringFromDB(tableName, primaryKeyName, primaryKeyValue); + return result == null ? null : Util.fromJsonString(result); + } + + private ObjectNode readJsonObjectFromDB(String tableName, String primaryKeyName, String primaryKeyValue, String sortKeyName, String sortKeyValue) throws IOException { + String result = readJsonStringFromDB(tableName, primaryKeyName, primaryKeyValue, sortKeyName, sortKeyValue); + return result == null ? null : Util.fromJsonString(result); + } + + private String readJsonStringFromDB(String tableName, String primaryKeyName, String primaryKeyValue) throws IOException { + DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); + Table table = dynamoDB.getTable(tableName); + ItemCollection items = table.query(primaryKeyName, primaryKeyValue); + try { + Iterator iterator = items.iterator(); + if (iterator.hasNext()) + return iterator.next().toJSON(); + else + return null; + } catch (ResourceNotFoundException ex) { + return null; + } + } + + private ObjectNode readNewestJsonObjectFromDB(String tableName, String primaryKeyName, String primaryKeyValue) { + DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); + Table table = dynamoDB.getTable(tableName); + QuerySpec spec = new QuerySpec() + .withKeyConditionExpression(primaryKeyName + " = :primaryKey" ) + .withValueMap(new ValueMap().withString(":primaryKey", primaryKeyValue)) + .withScanIndexForward(false); + + ItemCollection items = table.query(spec); + try { + Iterator iterator = items.iterator(); + if (iterator.hasNext()) { + String result = iterator.next().toJSON(); + try { + return Util.fromJsonString(result); + } catch (IOException ex) { + return null; + } + } + else + return null; + } catch (ResourceNotFoundException ex) { + return null; + } + } + + private String readJsonStringFromDB(String tableName, String primaryKeyName, String primaryKeyValue, String sortKeyName, String sortKeyValue) throws IOException { + DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); + Table table = dynamoDB.getTable(tableName); + ItemCollection items = table.query(primaryKeyName, primaryKeyValue, + new RangeKeyCondition(sortKeyName).eq(sortKeyValue)); + try { + Iterator iterator = items.iterator(); + if (iterator.hasNext()) + return iterator.next().toJSON(); + else + return null; + } catch (ResourceNotFoundException ex) { + return null; + } + } + + private Map readJsonObjectsFromDB(String tableName, String primaryKeyName, String primaryKeyValue, String sortKeyName) throws IOException { + DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); + Table table = dynamoDB.getTable(tableName); + ItemCollection items = table.query(primaryKeyName, primaryKeyValue); + Iterator iterator = items.iterator(); + Map itemMap = new HashMap<>(); + while (iterator.hasNext()) { + Item item = iterator.next(); + String key = item.getString(sortKeyName); + itemMap.put(key, Util.fromJsonString(item.toJSON())); + } + return itemMap; + } + + private void writeJsonObjectToDB(String tableName, JsonNode obj) { + DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); + Table table = dynamoDB.getTable(tableName); + table.putItem( + new PutItemSpec() + .withItem(Item.fromJSON(obj.toString())) + ); + } + + private boolean writeNewerJsonObjectToDB(String tableName, JsonNode obj, String primaryKeyName, String timeStampKeyName) { + DynamoDB dynamoDB = new DynamoDB(amazonDynamoDB); + Table table = dynamoDB.getTable(tableName); + /* + To prevent a new item from replacing an existing item, use a conditional expression that contains the attribute_not_exists function with the name of the attribute being used as the partition key for the table. Since every record must contain that attribute, the attribute_not_exists function will only succeed if no matching item exists. + https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/SQLtoNoSQL.WriteData.html + + Apparently, the simpler putItem(item, conditionalExpression, nameMap, valueMap) swallows the ConditionalCheckFailedException + */ + String conditionalExpression = "attribute_not_exists(" + primaryKeyName + ") OR " + timeStampKeyName + " < :" + timeStampKeyName; + try { + table.putItem( + new PutItemSpec() + .withItem(Item.fromJSON(obj.toString())) + .withConditionExpression(conditionalExpression) + .withValueMap(Collections.singletonMap(":" + timeStampKeyName, obj.get(timeStampKeyName).asText()))); + return true; + } catch(ConditionalCheckFailedException e) { + // https://github.com/aws/aws-sdk-java/issues/1945 + logger.log(Logger.Level.WARNING, "writeNewerJsonObjectToDB: " + e.getMessage() + " " + obj); + return false; + } + } +} + +/* + +Stores the assignment data in the local file system. + +root/ + Problems/ + repo1 + key11.zip + key12.zip + ... + repo2 + key21.zip + key22.zip + ... + + CodeCheckAssignments/ + assignmentID1 <- JSON file like in DynamoDB + assignmentID2 + ... + + CodeCheckLTICredentials <- JSON object mapping consumer keys to shared secrets + + CodeCheckComments + assignmentID1 + workID11 <- the comment + workID12 + ... + assignmentID2 + workID21 + workID22 + ... + ... + + CodeCheckWork + assignmentID1 + workID11 <- JSON file like in DynamoDB + workID12 + ... + assignmentID2 + workID21 + workID22 + ... + ... + + CodeCheckSubmissions + submissionID1 + timestamp11 <- JSON file like in DynamoDB + timestamp12 + ... + submissionID2 + timestamp21 + timestamp22 + ... + ... + +Legacy LTI resources are not supported. + +*/ + +class LocalStorageConnection implements StorageConnection { + private Path root; + private static Logger logger = System.getLogger("com.horstmann.codecheck"); + private ObjectNode credentials; + + public LocalStorageConnection(Config config) { + this.root = Path.of(config.getString("com.horstmann.codecheck.storage.local")); + try { + Files.createDirectories(root); + } catch (IOException ex) { + logger.log(Logger.Level.ERROR, "Cannot create " + root); + } + } + + public byte[] readProblem(String repo, String key) throws IOException { + byte[] result = null; + try { + Path repoPath = root.resolve("Problems").resolve(repo); + Path filePath = repoPath.resolve(key + ".zip"); + result = Files.readAllBytes(filePath); + } catch (IOException ex) { + logger.log(Logger.Level.ERROR, "ProblemLocalConnection.read : Cannot read " + key + " from " + repo); + throw ex; + } + + return result; + } + + public void writeProblem(byte[] contents, String repo, String key) throws IOException { + try { + Path repoPath = root.resolve("Problems").resolve(repo); + Files.createDirectories(repoPath); + Path newFilePath = repoPath.resolve(key + ".zip"); + Files.write(newFilePath, contents); + } catch (IOException ex) { + String bytes = Arrays.toString(contents); + logger.log(Logger.Level.ERROR, "ProblemLocalConnection.write : Cannot put " + bytes.substring(0, Math.min(50, bytes.length())) + "... to " + repo); + throw ex; + } + } + + + public ObjectNode readAssignment(String assignmentID) throws IOException { + return readJsonObject("CodeCheckAssignments", assignmentID); + } + + public String readLegacyLTIResource(String resourceID) throws IOException { + return null; + } + + public String readLTISharedSecret(String oauthConsumerKey) throws IOException { + if (credentials == null) + credentials = Util.fromJsonString(Files.readString(path("CodeCheckLTICredentials"))); + return credentials.get("shared_secret").asText(); + } + + public String readComment(String assignmentID, String workID) throws IOException { + Path path = path("CodeCheckComments", assignmentID, workID); + if (Files.exists(path)) return Files.readString(path); + else return ""; + } + + public ObjectNode readWork(String assignmentID, String workID) throws IOException { + return readJsonObject("CodeCheckWork", assignmentID, workID); + } + + public String readWorkString(String assignmentID, String workID) throws IOException { + return readJsonString("CodeCheckWork", assignmentID, workID); + } + + public ObjectNode readNewestSubmission(String submissionID) throws IOException { + Path path = path("CodeCheckSubmissions", submissionID); + + try (Stream entries = Files.list(path)) { + Path latest = entries.filter(Files::isRegularFile).max(Path::compareTo).orElse(null); + if (latest == null) return null; + String content = Files.readString(latest); + try { + return Util.fromJsonString(content); + } catch (JsonProcessingException ex) { + logger.log(Logger.Level.WARNING, "AssignmentConnector.readNewestJsonObjectFromDB: cannot read " + latest.toString() + "***File content: " + content); + return null; + } + } catch (IOException ex) { + return null; + } + } + + public Map readAllWork(String assignmentID) throws IOException { + Map itemMap = new HashMap<>(); + Path path = path("CodeCheckWork", assignmentID); + try { + try (Stream entries = Files.list(path)) { + List files = entries.filter(Files::isRegularFile).collect(Collectors.toList()); + for (Path file : files) { + String fileData = Files.readString(file); + ObjectNode node = Util.fromJsonString(fileData); + String key = node.get("workID").asText(); + itemMap.put(key, node); + } + } + } catch (IOException ex){ + logger.log(Logger.Level.WARNING, Util.getStackTrace(ex)); + } + return itemMap; + } + + public void writeAssignment(JsonNode node) throws IOException { + String assignmentID = node.get("assignmentID").asText(); + Path path = path("CodeCheckAssignments", assignmentID); + Files.createDirectories(path.getParent()); + Files.writeString(path, node.toString()); + } + + public void writeSubmission(JsonNode node) throws IOException { + String submissionID = node.get("submissionID").asText(); + String submittedAt = node.get("submittedAt").asText(); + Path path = path("CodeCheckSubmissions", submissionID, submittedAt); + Files.createDirectories(path.getParent()); + Files.writeString(path, node.toString()); + } + + public void writeComment(JsonNode node) throws IOException { + String assignmentID = node.get("assignmentID").asText(); + String workID = node.get("workID").asText(); + Path path = path("CodeCheckComments", assignmentID, workID); + Files.createDirectories(path.getParent()); + Files.writeString(path, node.get("comment").asText()); + } + + public boolean writeWork(JsonNode node) throws IOException { + String assignmentID = node.get("assignmentID").asText(); + String workID = node.get("workID").asText(); + Path path = path("CodeCheckWork", assignmentID, workID); + Files.createDirectories(path.getParent()); + String newTimeStampVal = node.get("submittedAt").asText(); + + int tries = 10; + while (tries > 0) { + FileChannel channel = FileChannel.open(path, + StandardOpenOption.READ, StandardOpenOption.WRITE, StandardOpenOption.CREATE); + try (FileLock lock = channel.lock()) { + ByteBuffer readBuffer = ByteBuffer.allocate((int) channel.size()); + channel.read(readBuffer); + byte[] contents = readBuffer.array(); + boolean replace = false; + if (contents.length == 0) replace = true; + else { + ObjectNode prevNode = Util.fromJsonString(readBuffer.array()); + String prevTimeStampVal = prevNode.get("submittedAt").asText(); + replace = prevTimeStampVal.compareTo(newTimeStampVal) < 0; + } + if (replace) { + channel.truncate(0); + ByteBuffer writeBuffer = ByteBuffer.wrap(node.toString().getBytes()); + channel.write(writeBuffer); + return true; + } + else return false; + } catch (FileLockInterruptionException ex) { + try { Thread.sleep(1000); } catch (InterruptedException ex2) {} + tries--; + } + } + throw new IOException("Could not acquire lock for " + path); + } + + private Path path(String first, String... rest) { + Path result = root.resolve(first); + for (String r : rest) result = result.resolve(r.replaceAll("[^a-zA-Z0-9_-]", "")); + return result; + } + + private ObjectNode readJsonObject(String tableName, String primaryKeyValue) throws IOException { + String result = readJsonString(tableName, primaryKeyValue); + return result == null ? null : Util.fromJsonString(result); + } + + private ObjectNode readJsonObject(String tableName, String primaryKeyValue, String sortKeyValue) throws IOException { + String result = readJsonString(tableName, primaryKeyValue, sortKeyValue); + return result == null ? null : Util.fromJsonString(result); + } + + public String readJsonString(String tableName, String primaryKeyValue) throws IOException { + Path path = path(tableName, primaryKeyValue); + + try { + return Files.readString(path); + } catch (IOException ex) { + logger.log(Logger.Level.WARNING, "AssignmentLocalConnection.readJsonString: Cannot read " + path); + return null; + } + } + + public String readJsonString(String tableName, String primaryKeyValue, String sortKeyValue) throws IOException { + Path path = path(tableName, primaryKeyValue, sortKeyValue); + + try { + return Files.readString(path); + } catch (IOException ex) { + logger.log(Logger.Level.WARNING, "AssignmentLocalConnection.readJsonString: Cannot read " + path); + return null; + } + } +} + +/* + +CREATE TABLE CodeCheckAssignments (assignmentID VARCHAR PRIMARY KEY, json VARCHAR) +CREATE TABLE CodeCheckLTICredentials (oauth_consumer_key VARCHAR PRIMARY KEY, shared_secret VARCHAR) +CREATE TABLE CodeCheckComments (assignmentID VARCHAR, workID VARCHAR, comment VARCHAR, UNIQUE (assignmentID, workID)) +CREATE TABLE CodeCheckWork (assignmentID VARCHAR, workID VARCHAR, submittedAt VARCHAR, json VARCHAR, UNIQUE (assignmentID, workID)) +CREATE TABLE CodeCheckSubmissions (submissionID VARCHAR, submittedAt VARCHAR, json VARCHAR) + + */ + +class SQLStorageConnection implements StorageConnection { + private static Logger logger = System.getLogger("com.horstmann.codecheck"); + private Config config; + + public SQLStorageConnection(Config config) { + this.config = config; + } + + public byte[] readProblem(String repo, String key) throws IOException { + try (Connection conn = config.getDatabaseConnection()) { + PreparedStatement ps = conn.prepareStatement("SELECT contents FROM Problems WHERE repo = ? AND key = ?"); + ps.setString(1, repo); + ps.setString(2, key); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return rs.getBytes(1); + else return null; + } catch (SQLException ex) { + logger.log(Logger.Level.ERROR, ex.getMessage()); + throw new IOException(ex); + } + } + + public void writeProblem(byte[] contents, String repo, String key) throws IOException { + try { + try (Connection conn = config.getDatabaseConnection()) { + // https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS + PreparedStatement ps = conn.prepareStatement(""" +INSERT INTO Problems VALUES (?, ?, ?) +ON CONFLICT (repo, key) +DO UPDATE SET contents = EXCLUDED.contents +"""); + ps.setString(1, repo); + ps.setString(2, key); + ps.setBytes(3, contents); + ps.executeUpdate(); + } + } catch (SQLException ex) { + logger.log(Logger.Level.ERROR, ex.getMessage()); + throw new IOException(ex); + } + } + + public ObjectNode readAssignment(String assignmentID) throws IOException { + try (Connection conn = config.getDatabaseConnection()) { + PreparedStatement ps = conn.prepareStatement("SELECT json FROM CodeCheckAssignments WHERE assignmentID = ?"); + ps.setString(1, assignmentID); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return Util.fromJsonString(rs.getString(1)); + else return null; + } catch (SQLException ex) { + throw new IOException(ex); + } + } + + public String readLegacyLTIResource(String resourceID) throws IOException{ + return null; + } + + public String readLTISharedSecret(String oauthConsumerKey) throws IOException{ + try (Connection conn = config.getDatabaseConnection()) { + PreparedStatement ps = conn.prepareStatement("SELECT shared_secret FROM CodeCheckLTICredentials WHERE oauth_consumer_key = ?"); + ps.setString(1, oauthConsumerKey); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return rs.getString(1); + else return null; + } catch (SQLException ex) { + throw new IOException(ex); + } + } + + public String readComment(String assignmentID, String workID) throws IOException{ + try (Connection conn = config.getDatabaseConnection()) { + PreparedStatement ps = conn.prepareStatement("SELECT comment FROM CodeCheckComments WHERE assignmentID = ? AND workID = ?"); + ps.setString(1, assignmentID); + ps.setString(2, workID); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return rs.getString(1); + else return null; + } catch (SQLException ex) { + throw new IOException(ex); + } + } + + public ObjectNode readWork(String assignmentID, String workID) throws IOException{ + String result = readWorkString(assignmentID, workID); + return result == null ? null : Util.fromJsonString(result); + } + + public String readWorkString(String assignmentID, String workID) throws IOException{ + try (Connection conn = config.getDatabaseConnection()) { + PreparedStatement ps = conn.prepareStatement("SELECT json FROM CodeCheckWork WHERE assignmentID = ? AND workID = ?"); + ps.setString(1, assignmentID); + ps.setString(2, workID); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return rs.getString(1); + else return null; + } catch (SQLException ex) { + throw new IOException(ex); + } + } + + public ObjectNode readNewestSubmission(String submissionID) throws IOException{ + try (Connection conn = config.getDatabaseConnection()) { + PreparedStatement ps = conn.prepareStatement( +""" +SELECT json FROM CodeCheckSubmissions WHERE submissionID = ? AND submittedAT = + (SELECT MAX(submittedAt) FROM CodeCheckSubmissions WHERE submissionID = ?) +"""); + ps.setString(1, submissionID); + ps.setString(2, submissionID); + ResultSet rs = ps.executeQuery(); + if (rs.next()) return Util.fromJsonString(rs.getString(1)); + else return null; + } catch (SQLException ex) { + throw new IOException(ex); + } + + } + + public Map readAllWork(String assignmentID) throws IOException{ + Map result = new HashMap<>(); + try (Connection conn = config.getDatabaseConnection()) { + PreparedStatement ps = conn.prepareStatement("SELECT workID, json FROM CodeCheckWork WHERE assignmentID = ?"); + ps.setString(1, assignmentID); + ResultSet rs = ps.executeQuery(); + while (rs.next()) + result.put(rs.getString(1), Util.fromJsonString(rs.getString(2))); + return result; + } catch (SQLException ex) { + throw new IOException(ex); + } + } + + public void writeAssignment(JsonNode node) throws IOException { + try { + try (Connection conn = config.getDatabaseConnection()) { + // https://wiki.postgresql.org/wiki/What's_new_in_PostgreSQL_9.5#INSERT_..._ON_CONFLICT_DO_NOTHING.2FUPDATE_.28.22UPSERT.22.29 + PreparedStatement ps = conn.prepareStatement(""" +INSERT INTO CodeCheckAssignments VALUES (?, ?) +ON CONFLICT (assignmentID) +DO UPDATE SET json = EXCLUDED.json +"""); + ps.setString(1, node.get("assignmentID").asText()); + ps.setString(2, node.toString()); + ps.executeUpdate(); + } + } catch (SQLException ex) { + logger.log(Logger.Level.ERROR, ex.getMessage()); + throw new IOException(ex); + } + } + + public void writeSubmission(JsonNode node) throws IOException { + try { + try (Connection conn = config.getDatabaseConnection()) { + PreparedStatement ps = conn.prepareStatement("INSERT INTO CodeCheckSubmissions VALUES (?, ?, ?)"); + ps.setString(1, node.get("submissionID").asText()); + ps.setString(2, node.get("submittedAt").asText()); + ps.setString(3, node.toString()); + ps.executeUpdate(); + } + } catch (SQLException ex) { + logger.log(Logger.Level.ERROR, ex.getMessage()); + throw new IOException(ex); + } + } + + public void writeComment(JsonNode node) throws IOException { + try { + try (Connection conn = config.getDatabaseConnection()) { + // https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS + PreparedStatement ps = conn.prepareStatement(""" +INSERT INTO CodeCheckComments VALUES (?, ?, ?) +ON CONFLICT (assignmentID, workID) +DO UPDATE SET comment = EXCLUDED.comment +"""); + ps.setString(1, node.get("assignmentID").asText()); + ps.setString(2, node.get("workID").asText()); + ps.setString(3, node.get("comment").asText()); + ps.executeUpdate(); + } + } catch (SQLException ex) { + logger.log(Logger.Level.ERROR, ex.getMessage()); + throw new IOException(ex); + } + } + + public boolean writeWork(JsonNode node) throws IOException { + try { + try (Connection conn = config.getDatabaseConnection()) { + // https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS + PreparedStatement ps = conn.prepareStatement(""" +INSERT INTO CodeCheckWork VALUES (?, ?, ?, ?) +ON CONFLICT (assignmentID, workID) +DO UPDATE SET submittedAt = EXCLUDED.submittedAt, json = EXCLUDED.json +WHERE CodeCheckWork.submittedAt < EXCLUDED.submittedAt +"""); + ps.setString(1, node.get("assignmentID").asText()); + ps.setString(2, node.get("workID").asText()); + ps.setString(3, node.get("submittedAt").asText()); + ps.setString(4, node.toString()); + int rowcount = ps.executeUpdate(); + return rowcount > 0; + } + } catch (SQLException ex) { + logger.log(Logger.Level.ERROR, ex.getMessage()); + throw new IOException(ex); + } + } +} + diff --git a/app/models/Util.java b/app/models/Util.java deleted file mode 100644 index 7cb9e60f..00000000 --- a/app/models/Util.java +++ /dev/null @@ -1,27 +0,0 @@ -package models; - -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; - -import play.mvc.Http; - -public class Util { - public static String prefix(Http.Request request) { - boolean secure = request.secure() || request.getHeaders().getAll("X-Forwarded-Proto").contains("https"); - /* - One shouldn't have to do this, but with Google Cloud, X-Forwarded-For has two entries (e.g. [95.90.234.41, 130.211.33.19]) - and X-Forwarded-Proto has one ([https]). From - https://github.com/playframework/playframework/blob/814f0c73f86eb0e85bcae7f2167c73a08fed9fd7/transport/server/play-server/src/main/scala/play/core/server/common/ForwardedHeaderHandler.scala - line 204, Play doesn't conclude that the connection was secure. - */ - return (secure ? "https://" : "http://") + request.host(); - } - - public static ObjectNode toJson(Object obj) { - ObjectMapper mapper = new ObjectMapper(); - mapper.setSerializationInclusion(Include.NON_DEFAULT); - return (ObjectNode) mapper.convertValue(obj, JsonNode.class); - } -} diff --git a/app/views/edit.scala.html b/app/views/edit.scala.html deleted file mode 100644 index 32414240..00000000 --- a/app/views/edit.scala.html +++ /dev/null @@ -1,67 +0,0 @@ -@(pid: String, files: Map[String, String], editKey: String) - - - - - - Edit Problem - - -

-
- @for(((k, v), i) <- files.toSeq.zipWithIndex) { -
-

File name: - -

-

-
- } -
Need more files?
-
-
-
-

-
-
-

Alternatively, upload a zip file. Select the file

-

- - - - diff --git a/app/views/editAssignment.scala.html b/app/views/editAssignment.scala.html deleted file mode 100644 index 4555f7a3..00000000 --- a/app/views/editAssignment.scala.html +++ /dev/null @@ -1,29 +0,0 @@ -@(assignment: String, askForDeadline: Boolean) - - - - - - - - - - Edit Assignment - - -

Edit Assignment

-
-

Enter problem URLs or IDs, one per line:

- -
-

Deadline (In UTC):

- -

Leave date blank for no deadline.

-
-
- - - \ No newline at end of file diff --git a/app/views/index.scala.html b/app/views/index.scala.html deleted file mode 100644 index d15a8651..00000000 --- a/app/views/index.scala.html +++ /dev/null @@ -1,7 +0,0 @@ -@(message: String) - -@main("Welcome to Play") { - - - -} diff --git a/app/views/lti_config.scala.xml b/app/views/lti_config.scala.xml deleted file mode 100644 index e5cb81e5..00000000 --- a/app/views/lti_config.scala.xml +++ /dev/null @@ -1,30 +0,0 @@ -@(host: String) - - - CodeCheck - CodeCheck Assignments - - https://@host/assignment - - 320576af-a6a1-41f8-b634-2ee4ea4daafc - anonymous - @host - - https://@host/lti/createAssignment - CodeCheck - 1000 - 800 - true - - - - - \ No newline at end of file diff --git a/app/views/main.scala.html b/app/views/main.scala.html deleted file mode 100644 index aff0eff1..00000000 --- a/app/views/main.scala.html +++ /dev/null @@ -1,15 +0,0 @@ -@(title: String)(content: Html) - - - - - - @title - - - - - - @content - - diff --git a/app/views/viewSubmissions.scala.html b/app/views/viewSubmissions.scala.html deleted file mode 100644 index cb69f4d7..00000000 --- a/app/views/viewSubmissions.scala.html +++ /dev/null @@ -1,19 +0,0 @@ -@(allSubmissionsURL: String, submissions: String) - - - - - - - - - - Assignment Submissions - - - - - \ No newline at end of file diff --git a/app/views/workAssignment.scala.html b/app/views/workAssignment.scala.html deleted file mode 100644 index fb794b6c..00000000 --- a/app/views/workAssignment.scala.html +++ /dev/null @@ -1,49 +0,0 @@ -@(assignment: String, work: String, studentID: String, lti: String) - - - - - - - - - - Your Assignment - - -

Your Assignment

-
-

You are viewing this assignment as instructor.

-
-
Public URL for your students:
-
Private URL for you only:
-
-

ID:

-
-
-

Your CodeCheck ID:

-
    -
  • Share this ID with your professor
  • -
  • If you work on a shared computer, clear the ID after you are done
  • -
-
-
Use this private URL for resuming your work later:
-
-
-

I saved a copy of the private URL

-
-
-

You are viewing this assignment from a Learning Management System (LMS)

-

Your LMS ID:

-

Wrong score in LMS?

-
-

-

Click on the buttons below to view all parts of the assignment.

- - \ No newline at end of file diff --git a/build-instructions.md b/build-instructions.md index 537245fa..0ade3a0c 100644 --- a/build-instructions.md +++ b/build-instructions.md @@ -1,8 +1,6 @@ -CodeCheck^®^ Build Instructions -=============================== +# CodeCheck® Build Instructions -Program Structure ------------------ +## Program Structure CodeCheck has two parts: @@ -30,27 +28,58 @@ This tool uses only the part of `play-codecheck` that deals with checking a problem (in the `com.horstmann.codecheck` package). The tool is called `codecheck`. It is created by the `cli/build.xml` Ant script. -Building the Command Line Tool ------------------------------- +## Special Steps for Github Codespaces + +Make a new Codespace by cloning the repository `cayhorstmann/codecheck2` + +Open a terminal. Run + +``` +sudo sed -i -e 's/root/ALL/' /etc/sudoers.d/codespace +sudo cat /etc/sudoers.d/codespace +``` + +and verify that the contents is + +``` +codespace ALL=(ALL) NOPASSWD:ALL +``` + +## Install Codecheck dependencies -These instructions are for Ubuntu 20.04LTS. +These instructions are for Ubuntu 20.04LTS. If you are not running Ubuntu natively, run it in a virtual machine. If you were asked to use Github Codespaces, that should be set up for you. Otherwise, you need to set up your own virtual machine. These instructions should be helpful: https://horstmann.com/pfh/2021/vm.html -Install the following software: +Open a terminal and install the dependencies - sudo apt install openjdk-11-jdk git ant curl unzip +``` +sudo apt update +sudo apt -y install openjdk-11-jdk git ant curl zip unzip +``` -Make a directory `/opt/codecheck` and a subdirectory `ext` that you own: +Install sbt for Linux (deb) or [follow the instruction for your environment](https://www.scala-sbt.org/download.html) +``` +echo "deb https://repo.scala-sbt.org/scalasbt/debian all main" | sudo tee /etc/apt/sources.list.d/sbt.list +echo "deb https://repo.scala-sbt.org/scalasbt/debian /" | sudo tee /etc/apt/sources.list.d/sbt_old.list +curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" | sudo apt-key add +sudo apt update +sudo apt -y install sbt +``` +Building the Command Line Tool +------------------------------ - sudo mkdir -p /opt/codecheck/ext - export ME=$(whoami) ; sudo -E chown $ME /opt/codecheck /opt/codecheck/ext +Make a directory `/opt/codecheck` that you own: -Clone the repo: + sudo mkdir -p /opt/codecheck + export ME=$(whoami) ; sudo -E chown $ME /opt/codecheck + +Clone the repo (unless you are in Codespaces, where it is already cloned) git clone https://github.com/cayhorstmann/codecheck2 Get a few JAR files: - cd codecheck2/cli + cd codecheck2 # if not already there + cd cli mkdir lib cd lib curl -LOs https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-core/2.6.4/jackson-core-2.6.4.jar @@ -62,11 +91,10 @@ Get a few JAR files: curl -LOs https://repo1.maven.org/maven2/com/puppycrawl/tools/checkstyle/8.42/checkstyle-8.42.jar curl -LOs https://repo1.maven.org/maven2/org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar curl -LOs https://repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar - cd ../../../.. + cd ../../.. Build the command-line tool: - cd codecheck2 ant -f cli/build.xml Test that it works: @@ -76,27 +104,44 @@ Test that it works: If you omit the `-t`, you get a report with your default browser instead of the text report. -Debugging the Command Line Tool -------------------------------- +## Eclipse -If you are making changes to the part of CodeCheck that does the actual -code checking, such as adding a new language, and you need to run a -debugger, it is easiest to debug the command line tool. +If you work on your own machine, I recommend Eclipse as the IDE. If you use Codespaces, skip this section and read about the Visual Studio Code configuration instead. -Make directories for the submission and problem files, and populate them -with samples. +Install [Eclipse](https://www.eclipse.org/eclipseide/), following the instructions of the provider. + +Run + + sbt eclipse + sbt compile + +Then open Eclipse and import the created project. + +Make two debugger configurations. Select Run → Debug Configurations, +right-click on Remote Java Application, and select New Configuration. -In your debug configuration, set: +For the first configuration, specify: -- The main class to +- Type: Remote Java Application +- Name: Play Server +- Project: `play-codecheck` +- Connection type: Standard +- Host: `localhost` +- Port: 9999 + +For the second debug configuration, set: + +- Type: Java Application +- Name: Command Line Tool +- Main class: com.horstmann.codecheck.Main -- Program arguments to +- Program arguments: - /path/to/submissiondir /path/to/problemdir + /tmp/submission /tmp/problem -- VM arguments to +- VM arguments: -Duser.language=en -Duser.country=US @@ -104,79 +149,133 @@ In your debug configuration, set: -Dcom.horstmann.codecheck.report=HTML -Dcom.horstmann.codecheck.debug -- The environment variable `COMRUN_USER` to your username +- Environment variable `COMRUN_USER`: your username -To debug on Windows or MacOS, you have to use the Docker container for -compilation and execution. +## Codespaces and Visual Studio Code - docker build --tag comrun:1.0-SNAPSHOT comrun - docker run -p 8080:8080 -it comrun:1.0-SNAPSHOT +If you use Codespaces, you need to use Visual Studio Code as your IDE. If not, skip this section and follow the section about configuring Eclipse instead. -Point your browser to to check that -the container is running. +Run -When debugging, add the VM argument + sbt eclipse + sbt compile + +Visual Studio Code will read the project configuration from the Eclipse configuration. + +Install the Language Support for Java (from Red Hat) and Debugger for Java (from Microsoft) extensions into Visual Studio Code. + +In Visual Studio Code, click on the Run and Debug (triangle and bug) icon on the left. Select Run → Add Configuration from the menu. The file `.vscode/launch.json` is opened up. Set it to the following contents: + +``` +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "Debug (Attach)", + "request": "attach", + "hostName": "localhost", + "port": 9999, + "projectName": "play-codecheck" + }, + { + "type": "java", + "name": "Launch Main", + "request": "launch", + "mainClass": "com.horstmann.codecheck.Main", + "projectName": "play-codecheck", + "args": "/tmp/submission /tmp/problem", + "vmArgs": [ + "-Duser.language=en", + "-Duser.country=US", + "-Dcom.horstmann.codecheck.comrun.local=/opt/codecheck/comrun", + "-Dcom.horstmann.codecheck.report=HTML", + "-Dcom.horstmann.codecheck.debug" + ], + "env": { "COMRUN_USER": "codespace" } + } + ] +} +``` + +Sad Codespaces/Visual Studio Code issue: When you install the Java language pack, the terminal is configured to use the version of Java installed with the language pack. That is *not* what we want. *Every time* you open the terminal, do this: + +``` +export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 +export PATH=$JAVA_HOME/bin:$PATH +java -version +``` + +Be sure that you get Java 11, and not Java 17. - -Dcom.horstmann.codecheck.comrun.remote=http://localhost:8080/api/upload +Debugging the Command Line Tool +------------------------------- -Building the Web Application ----------------------------- +If you are making changes to the part of CodeCheck that does the actual +code checking, such as adding a new language, and you need to run a +debugger, it is easiest to debug the command line tool. + +Make directories for the submission and problem files, and populate them +with samples. For example, -Install, following the instructions of the providers, +``` +rm -rf /tmp/submission /tmp/problem +mkdir /tmp/submission +mkdir /tmp/problem +cp samples/java/example1/*.java /tmp/submission +cp -R samples/java/example1 /tmp/problem +``` +Set a breakpoint in app/com/horstmann/codecheck/Main.java and launch the debugger with the Command Line Tool configuration. -- [SBT](https://www.scala-sbt.org/download.html) -- [Eclipse](https://www.eclipse.org/eclipseide/) +Building the Server +------------------- Run the `play-codecheck` server: - sbt run + COMRUN_USER=$(whoami) sbt run -Point the browser to . +Point the browser to . Upload a problem and test it. -Note: The problem files will be located inside the `/opt/codecheck/ext` +Note: The problem files will be located inside the `/opt/codecheck/repo/ext` directory. Debugging the Server -------------------- -Import the project into Eclipse. Run - - sbt eclipse - -Then open Eclipse and import the created project. - -Make a debugger configuration. Select Run → Debug Configurations, -right-click on Remote Java Application, and select New Configuration. -Specify: - -- Project: `play-codecheck` -- Connection type: Standard -- Host: `localhost` -- Port: 9999 - Run the `play-codecheck` server in debug mode: - COMRUN_USER=$(whoami) sbt -jvm-debug 9999 run + COMRUN_USER=$(whoami) sbt -jvm-debug 9999 -Dconfig.file=conf/development.conf run In Eclipse, select Run → Debug Configurations, select the configuration you created, and select Debug. Point the browser to a URL such as -. Set breakpoints as +. Set breakpoints as needed. -Docker Deployment ------------------ +## Podman/Docker Installation -Install [Docker](https://docs.docker.com/engine/install/ubuntu/). +Skip this step if you are on Codespaces. Codespaces already has Docker installed. + +Install Podman and Podman-Docker: +``` +sudo apt-get podman podman-docker +sudo touch /etc/containers/nodocker +``` + +Docker Local Testing +-------------------- Build and run the Docker container for the `comrun` service: - docker build --tag codecheck:1.0-SNAPSHOT comrun - docker run -p 8080:8080 -it codecheck:1.0-SNAPSHOT + docker build --tag comrun:1.0-SNAPSHOT comrun + docker run -p 8080:8080 -it comrun:1.0-SNAPSHOT & Test that it works: - /opt/codecheck/codecheck -l samples/java/example1 & + /opt/codecheck/codecheck -lt samples/java/example1 Create a file `conf/production.conf` holding an [application secret](https://www.playframework.com/documentation/2.8.x/ApplicationSecret): @@ -186,52 +285,211 @@ secret](https://www.playframework.com/documentation/2.8.x/ApplicationSecret): Do not check this file into version control! -Build and run the Docker container for the `play-codecheck` server: +Build the Docker container for the `play-codecheck` server. sbt docker:publishLocal - docker run -p 9090:9000 -it --add-host host.docker.internal:host-gateway play-codecheck:1.0-SNAPSHOT + +Ignore the `[error]` labels during the Docker build. They aren't actually errors. +Run the container. If you do this on your own computer: + + docker run -p 9090:9000 -it play-codecheck:1.0-SNAPSHOT + Test that it works by pointing your browser to -. Upload a problem. +. + +On Codespaces: + + docker run -p 9090:9000 -it --add-host host.docker.internal:host-gateway play-codecheck:1.0-SNAPSHOT & + +Then locate the Ports tab and open the local address for port 9090. Ignore the nginx error and paste `/assets/uploadProblem.html` after the URL. + +To complete the test locally or on Codespaces, upload a problem: File name `Numbers.java`, file contents: + +``` +public class Numbers +{ +//CALL 3, 4 +//CALL -3, 3 +//CALL 3, 0 + public double average(int x, int y) + { + //HIDE + return 0.5 * (x + y); + //SHOW // Compute the average of x and y + } +} +``` + +Click the Submit Files button. You should see three passing test cases. + +Kill both containers by running this command in the terminal: + + docker container kill $(docker ps -q) + +Comrun Service Deployment on AWS +-------------------------------- -Kill both containers by running this command in another terminal: +[Install the AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) - docker container kill $(docker ps -q) +Confirm the installation with the following command -Comrun Service Deployment {#service-deployment} -------------------------- +``` +aws --version +``` -There are two parts to the CodeCheck server. We\'ll take them up one at -a time. The `comrun` service compiles and runs student programs, -isolated from the web app and separately scalable. +[Configure the AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html) -Here is how to deploy the `comrun` service to Google Cloud. +``` + aws configure –-profile your-username +``` -Make a Google Cloud Run project. Define a service `comrun`. +Set +* Access key ID +* Secret access key +* AWS Region +* Output format -Then run: +You should now have the two files, `.aws/credentials` and `.aws/config`. - export PROJECT=your Google project name - docker tag codecheck:1.0-SNAPSHOT gcr.io/$PROJECT/comrun - docker push gcr.io/$PROJECT/comrun +The ```.aws/credentials``` file should contain: +``` +[your-username] +aws_access_key_id=... +aws_secret_access_key=... +``` - gcloud run deploy comrun \ - --image gcr.io/$PROJECT/comrun \ - --port 8080 \ - --platform managed \ - --region us-central1 \ - --allow-unauthenticated \ - --min-instances=1 \ - --max-instances=50 \ - --memory=512Mi \ - --concurrency=40 +And the ```.aws/config``` file: +``` +[profile your-username] +region = your-region #example: us-west-2 +output = json -You should get a URL for the service. Make a note of it---it won\'t -change, and you need it in the next steps. To test that the service is -properly deployed, do this: +``` - export REMOTE_URL=the URL of the comrun service - cd path to/codecheck2 +Set environment variables: + +``` +export AWS_DEFAULT_PROFILE=your-username +ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text) +echo Account ID: $ACCOUNT_ID +REGION=$(aws configure get region) +echo Region: $REGION +``` + +If ```REGION=$(aws configure get region)``` shows up to be the incorrect region, check out this link https://docs.aws.amazon.com/general/latest/gr/apprunner.html and set your region to the correct one by typing + +``` +REGION = your-region +``` + +Create an IAM Accesss Role and attach a pre-existing policy for access to container repositories. + +``` +export TEMPFILE=$(mktemp) +export ROLE_NAME=AppRunnerECRAccessRole +cat < +
File:
+ + +``` + +the comrun service was deployed. + +To test that the service is working properly, do this: + + cd path-to-codecheck2-repo + export REMOTE_URL=service-URL /opt/codecheck/codecheck -rt samples/java/example1 You should get a report that was obtained by sending the compile and run @@ -240,41 +498,259 @@ jobs to your remote service. Alternatively, you can test with the locally running web app. In `conf/production.conf`, you need to add - com.horstmann.codecheck.comrun.remote= the URL of the comrun service + com.horstmann.codecheck.comrun.remote=service-URL/api/upload -Play Server Deployment {#server-deployment} +Using AWS Data Storage ---------------------- +Set environment variables and create a user in your Amazon AWS account: + +``` +ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text) +echo Account ID: $ACCOUNT_ID +REGION=$(aws configure get region) +echo Region: $REGION + +USERNAME=codecheck + +aws iam create-user --user-name $USERNAME +aws iam create-access-key --user-name $USERNAME + +# IMPORTANT: Record AccessKeyId and SecretAccessKey +``` + In Amazon S3, create a bucket whose name starts with the four characters `ext.` and an arbitrary suffix, such as `ext.mydomain.com` to hold the uploaded CodeCheck problems. Set the ACL so that the bucket owner has all access rights and nobody else has any. -In your Google Cloud Run project, add another service `play-codecheck`. +``` +# Change the suffix below +SUFFIX=mydomain.com + +aws s3 mb s3://ext.$SUFFIX + +cat < CodeCheckS3.json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:*" + ], + "Resource": [ + "arn:aws:s3:::ext.$SUFFIX" + ] + }, + { + "Effect": "Allow", + "Action": [ + "s3:*" + ], + "Resource": [ + "arn:aws:s3:::ext.$SUFFIX/*" + ] + } + ] +} +EOF + +aws iam create-policy --policy-name CodeCheckS3 --policy-document file://./CodeCheckS3.json + +aws iam attach-user-policy --user-name $USERNAME \ + --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/CodeCheckS3 +``` + +If you use CodeCheck with LTI, you need to set up an Amazon Dynamo database. Create the following tables: + +| Name | Partition key | Sort key | +| ------------------------- | ------------------ | ----------- | +| CodeCheckAssignments | assignmentID | | +| CodeCheckLTICredentials | oauth_consumer_key | | +| CodeCheckLTIResources | resourceID | | +| CodeCheckSubmissions | submissionID | submittedAt | +| CodeCheckWork | assignmentID | workID | +| CodeCheckComments | assignmentID | workID | + +The first three tables have no sort key. All types are `String`. + +``` +aws --region $REGION dynamodb create-table \ + --table-name CodeCheckAssignments \ + --attribute-definitions AttributeName=assignmentID,AttributeType=S \ + --key-schema AttributeName=assignmentID,KeyType=HASH \ + --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1 + +aws --region $REGION dynamodb create-table \ + --table-name CodeCheckLTICredentials \ + --attribute-definitions AttributeName=oauth_consumer_key,AttributeType=S \ + --key-schema AttributeName=oauth_consumer_key,KeyType=HASH \ + --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1 + +aws --region $REGION dynamodb create-table \ + --table-name CodeCheckLTIResources \ + --attribute-definitions AttributeName=resourceID,AttributeType=S \ + --key-schema AttributeName=resourceID,KeyType=HASH \ + --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1 + +aws --region $REGION dynamodb create-table \ + --table-name CodeCheckSubmissions \ + --attribute-definitions AttributeName=submissionID,AttributeType=S AttributeName=submittedAt,AttributeType=S \ + --key-schema AttributeName=submissionID,KeyType=HASH AttributeName=submittedAt,KeyType=RANGE \ + --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1 + +aws --region $REGION dynamodb create-table \ + --table-name CodeCheckWork \ + --attribute-definitions AttributeName=assignmentID,AttributeType=S AttributeName=workID,AttributeType=S \ + --key-schema AttributeName=assignmentID,KeyType=HASH AttributeName=workID,KeyType=RANGE \ + --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1 + +aws --region $REGION dynamodb create-table \ + --table-name CodeCheckComments \ + --attribute-definitions AttributeName=assignmentID,AttributeType=S AttributeName=workID,AttributeType=S \ + --key-schema AttributeName=assignmentID,KeyType=HASH AttributeName=workID,KeyType=RANGE \ + --provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1 + +cat < CodeCheckDynamo.json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + "dynamodb:BatchWriteItem", + "dynamodb:GetItem", + "dynamodb:BatchGetItem", + "dynamodb:Scan", + "dynamodb:Query", + "dynamodb:ConditionCheckItem" + ], + "Resource": [ + "arn:aws:dynamodb:us-west-1:$ACCOUNT_ID:table/CodeCheck*", + "arn:aws:dynamodb:us-west-1:$ACCOUNT_ID:table/CodeCheck*/index/*" + ] + } + ] +} +EOF + +aws iam create-policy --policy-name CodeCheckDynamo --policy-document file://./CodeCheckDynamo.json + +aws iam attach-user-policy --user-name $USERNAME \ + --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/CodeCheckDynamo + +aws iam list-attached-user-policies --user-name $USERNAME +``` + +You need to populate the `CodeCheckLTICredentials` table with at least one pair `oauth_consumer_key` and `shared_secret` (both of type `String`). These can be any values. I recommend to use the admin's email for `oauth_consumer_key` and a random password for `shared_secret`. + +``` +USERNAME=codecheck +PASSWORD=$(strings /dev/urandom | grep -E '[^ ]{8}' | head -1) +echo Password: $PASSWORD +aws dynamodb put-item --table-name CodeCheckLTICredentials --item '{"oauth_consumer_key":{"S":"'${USERNAME}'"},"shared_secret":{"S":"'${PASSWORD}'"}}' +``` + +Play Server Deployment (AWS) +---------------------------- + +Make another ECR repository to store in the play-codecheck service. Note that you need the `ACCOUNT_ID` and `REGION` environment variables from the comrun deployment. + + + ECR_REPOSITORY=ecr-play-codecheck + + aws ecr create-repository \ + --repository-name $ECR_REPOSITORY \ + --region $REGION Add the following to `conf/production.conf`: play.http.secret.key= see above - com.horstmann.codecheck.comrun.remote=comrun host URL/api/upload - com.horstmann.codecheck.s3.accessKey= your AWS credentials - com.horstmann.codecheck.s3.secretKey= - com.horstmann.codecheck.s3bucketsuffix="mydomain.com" + com.horstmann.codecheck.comrun.remote="comrun host URL/api/upload" + com.horstmann.codecheck.aws.accessKey= your AWS credentials + com.horstmann.codecheck.aws.secretKey= + com.horstmann.codecheck.s3.bucketsuffix="mydomain.com" com.horstmann.codecheck.s3.region=your AWS region such as "us-west-1" - com.horstmann.codecheck.repo.ext="" + com.horstmann.codecheck.dynamodb.region=your AWS region such as "us-west-1" com.horstmann.codecheck.storeLocation="" -Deploy the `play-codecheck` service: +Run + + sbt docker:publishLocal + +Upload the container image to the ECR repository + + docker images + PROJECT=play-codecheck + + docker tag $PROJECT:1.0-SNAPSHOT $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$ECR_REPOSITORY + + docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$ECR_REPOSITORY + +To see that we have pushed the docker image into the ECR repository run: + + aws ecr describe-images --repository-name $ECR_REPOSITORY --region $REGION - export PROJECT=your Google project name +Then deploy the play-codecheck service - docker tag play-codecheck:1.0-SNAPSHOT gcr.io/$PROJECT/play-codecheck - docker push gcr.io/$PROJECT/play-codecheck +``` +export TEMPFILE=$(mktemp) - gcloud run deploy play-codecheck \ - --image gcr.io/$PROJECT/play-codecheck \ - --port 9000 \ - --platform managed \ - --region us-central1 \ - --allow-unauthenticated \ - --min-instances=1 +cat < /dev/null if [ -z "$CHECK_STUDENT_DIR" ] ; then exit ; fi # Checking student files -lcd "$STARTDIR" +cd "$STARTDIR" SUBMISSIONDIR=`mktemp -d /tmp/codecheck.XXXXXXXXXX` if [ -e $PROBLEMDIR/student ] ; then diff --git a/comrun/Dockerfile b/comrun/Dockerfile index 677940e1..ff8e5190 100644 --- a/comrun/Dockerfile +++ b/comrun/Dockerfile @@ -1,10 +1,8 @@ -FROM ubuntu:20.04 +FROM ubuntu:24.04 RUN apt-get update && \ apt-get install -y software-properties-common -RUN add-apt-repository -y ppa:plt/racket - RUN apt-get update && apt-get install --no-install-recommends -y \ wget \ grep \ @@ -13,8 +11,8 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ sysstat \ sudo \ g++ \ - haskell-platform \ - openjdk-11-jdk-headless \ + haskell-compiler \ + openjdk-21-jdk-headless \ libxi6 \ libxtst6 \ libxrender1 \ @@ -22,6 +20,8 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ nodejs \ polyml \ libpolyml-dev \ + php-cli \ + phpunit \ python3 \ python3-tk \ python3-pillow \ @@ -29,8 +29,15 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ scala \ locales \ strace \ + rustc \ && rm -rf /var/lib/apt/lists/* + +ENV KOTLIN_VERSION 2.0.20 +RUN wget -q "https://github.com/JetBrains/kotlin/releases/download/v${KOTLIN_VERSION}/kotlin-compiler-${KOTLIN_VERSION}.zip" && \ + unzip kotlin-compiler-${KOTLIN_VERSION}.zip -d / && mv /kotlinc /usr/lib/ && rm kotlin-compiler-${KOTLIN_VERSION}.zip +ENV PATH="/usr/lib/kotlinc/bin:${PATH}" + # https://stackoverflow.com/questions/28405902/how-to-set-the-locale-inside-a-debian-ubuntu-docker-container RUN locale-gen --no-purge en_US.UTF-8 ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 @@ -46,8 +53,11 @@ RUN echo "Defaults:root !syslog, !pam_session" >> /etc/sudoers RUN echo "Defaults:comrunner !syslog, !pam_session" >> /etc/sudoers COPY bin /opt/codecheck -RUN chmod +x /opt/codecheck/comrun +RUN chmod 755 /opt/codecheck +#COPY --chmod 755 bin /opt/codecheck +#RUN chmod +x /opt/codecheck/comrun +#RUN --chmod 733 mkdir /tmp/codecheck RUN mkdir /tmp/codecheck RUN chmod 733 /tmp/codecheck diff --git a/comrun/bin/comrun b/comrun/bin/comrun index 9e112d91..54c998af 100755 --- a/comrun/bin/comrun +++ b/comrun/bin/comrun @@ -86,7 +86,7 @@ sudo -u $COMRUN_USER nice -15 ./runsolution 2>&1 if [[ -z $DEBUG ]] ; then sudo -u $COMRUN_USER rm -rf solution* fi -sudo -u $COMRUN_USER nice -15 ./runsubmission 2>&1 +sudo -E -u $COMRUN_USER nice -15 ./runsubmission 2>&1 mkdir -p out # In case there is no out diff --git a/comrun/bin/preload.sh b/comrun/bin/preload.sh index 0e6ecc75..fe57bd69 100644 --- a/comrun/bin/preload.sh +++ b/comrun/bin/preload.sh @@ -1,12 +1,11 @@ #!/bin/bash # TODO get env dynamically -JAVA_HOME=/opt/jdk1.8.0 CODECHECK_HOME=/opt/codecheck -PATH=$PATH:$JAVA_HOME/bin MAXOUTPUTLEN=10000 BASE=$(pwd) +PATH=$PATH:/usr/lib/kotlinc/bin # args: dir sourceDir sourceDir ... function prepare { @@ -27,10 +26,10 @@ function compile { mkdir -p $BASE/out/$DIR case _"$LANG" in _C) - gcc -std=c99 -g -o prog -lm $@ > $BASE/out/$DIR/_compile 2>&1 + gcc -std=c99 -g -o prog $@ -lm > $BASE/out/$DIR/_compile 2>&1 ;; _Cpp) - g++ -std=c++17 -Wall -Wno-sign-compare -g -o prog $@ 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_compile + g++ -std=c++20 -Wall -Wno-sign-compare -g -o prog $@ 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_compile ;; _CSharp) mcs -o Prog.exe $@ > $BASE/out/$DIR/_compile 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_compile @@ -42,9 +41,9 @@ function compile { ghc -o prog $@ > $BASE/out/$DIR/_compile 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_compile ;; _Java) - javac -cp .:$BASE/use/\*.jar $@ > $BASE/out/$DIR/_compile 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_compile + javac -cp .:$BASE/use/\* $@ > $BASE/out/$DIR/_compile 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_compile ;; - _JavaScript|_Matlab) + _Bash|_JavaScript|_Matlab|_PHP) touch $BASE/out/$DIR/_compile ;; _Racket) @@ -54,11 +53,17 @@ function compile { python3 -m py_compile $@ 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_compile ;; _Scala) - PATH=$PATH:$JAVA_HOME/bin $SCALA_HOME/bin/scalac $@ 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_compile + scalac $@ 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_compile ;; + _Kotlin) + kotlinc $@ 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_compile + ;; _SML) polyc -o prog $1 > $BASE/out/$DIR/_compile 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_compile ;; + _Rust) + rustc -g -o prog $1 > $BASE/out/$DIR/_compile 2>&1 + ;; *) echo Unknown language $LANG > $BASE/out/$DIR/_errors ;; @@ -88,7 +93,7 @@ function run { cd $BASE/$DIR mkdir -p $BASE/out/$ID case _"$LANG" in - _C|_Cpp|_Dart|_Haskell|_SML) + _C|_Cpp|_Dart|_Haskell|_Rust) ulimit -d 100000 -f 1000 -n 100 -v 100000 if [[ -e prog ]] ; then if [[ $INTERLEAVEIO == "true" ]] ; then @@ -98,20 +103,45 @@ function run { fi fi ;; + _SML) + ulimit -d 1000000 -f 1000 -n 100 -v 1000000 + if [[ -e prog ]] ; then + if [[ $INTERLEAVEIO == "true" ]] ; then + timeout -v -s 9 ${TIMEOUT}s ${CODECHECK_HOME}/interleaveio.py ./prog $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$ID/_run + else + timeout -v -s 9 ${TIMEOUT}s ./prog $@ < $BASE/in/$ID > $BASE/out/$ID/_run 2>&1 + fi + fi + ;; _Java) - # ulimit -d 1000000 -f 1000 -n 100 -v 10000000 + ulimit -d 1000000 -f 1000 -n 100 -v 10000000 if [[ -e ${MAIN/.java/.class} ]] ; then if [[ $INTERLEAVEIO == "true" ]] ; then - timeout -v -s 9 ${TIMEOUT}s ${CODECHECK_HOME}/interleaveio.py java -ea -Djava.awt.headless=true -Dcom.horstmann.codecheck -cp .:$BASE/use/\*.jar ${MAIN/.java/} $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$ID/_run + timeout -v -s 9 ${TIMEOUT}s ${CODECHECK_HOME}/interleaveio.py java -ea -Djava.awt.headless=true -Dcom.horstmann.codecheck -cp .:$BASE/use/\* ${MAIN/.java/} $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$ID/_run cat hs_err*log >> $BASE/out/$ID/_run 2> /dev/null rm -f hs_err*log else - timeout -v -s 9 ${TIMEOUT}s java -ea -Djava.awt.headless=true -Dcom.horstmann.codecheck -cp .:$BASE/use/\*.jar ${MAIN/.java/} $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$ID/_run + timeout -v -s 9 ${TIMEOUT}s java -ea -Djava.awt.headless=true -Dcom.horstmann.codecheck -cp .:$BASE/use/\* ${MAIN/.java/} $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$ID/_run cat hs_err*log >> $BASE/out/$ID/_run 2> /dev/null rm -f hs_err*log fi fi ;; + _Bash) + ulimit -d 10000 -f 1000 -n 100 -v 100000 + if [[ -e premain.sh ]] ; then + TMPFILE=$(mktemp) + echo "chmod -r main.sh" >> $TMPFILE + cat premain.sh >> $TMPFILE + echo -e "\n" >> $TMPFILE + cat $MAIN >> $TMPFILE + rm premain.sh + mv $TMPFILE $MAIN + fi + chmod +x *.sh + timeout -v -s 9 ${TIMEOUT}s bash $MAIN $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN >> $BASE/out/$ID/_run + cat $BASE/out/$ID/_run + ;; _CSharp) ulimit -d 10000 -f 1000 -n 100 -v 100000 if [[ -e Prog.exe ]] ; then @@ -128,8 +158,13 @@ function run { ulimit -d 10000 -f 1000 -n 100 -v 1000000 NO_AT_BRIDGE=1 timeout -v -s 9 ${TIMEOUT}s octave --no-gui $MAIN $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$ID/_run ;; + _PHP) + ulimit -d 10000 -f 1000 -n 100 -v 1000000 + timeout -v -s 9 ${TIMEOUT}s php $MAIN $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$ID/_run + ;; _Python) ulimit -d 100000 -f 1000 -n 100 -v 100000 + export CODECHECK=true if [[ -n $BASE/out/$DIR/_errors ]] ; then if [[ $INTERLEAVEIO == "true" ]] ; then timeout -v -s 9 ${TIMEOUT}s ${CODECHECK_HOME}/interleaveio.py python3 $MAIN $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$ID/_run @@ -139,7 +174,7 @@ function run { fi ;; _Racket) - ulimit -d 100000 -f 1000 -n 100 -v 1000000 + ulimit -d 1000000 -f 1000 -n 100 -v 1000000 if grep -qE '\(define\s+\(\s*main\s+' $MAIN ; then timeout -v -s 9 ${TIMEOUT}s racket -tm $MAIN $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN >> $BASE/out/$ID/_run else @@ -148,7 +183,11 @@ function run { ;; _Scala) ulimit -d 1000000 -f 1000 -n 100 -v 10000000 - timeout -v -s 9 ${TIMEOUT}s $SCALA_HOME/bin/scala ${MAIN/.scala/} $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$ID/_run + timeout -v -s 9 ${TIMEOUT}s scala ${MAIN/.scala/} $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$ID/_run + ;; + _Kotlin) + ulimit -d 1000000 -f 1000 -n 100 -v 10000000 + timeout -v -s 9 ${TIMEOUT}s kotlin ${MAIN/.kt/Kt} $@ < $BASE/in/$ID 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$ID/_run ;; *) echo Unknown language $LANG > $BASE/out/$ID/_run @@ -171,7 +210,7 @@ function unittest { cd $BASE/$DIR case _"$LANG" in _Java) - javac -cp .:$BASE/use/\*:$CODECHECK_HOME/lib/* $MAIN $@ 2>&1 | head --lines $MAXOUTPUTLEN> $BASE/out/$DIR/_compile + javac -cp .:$BASE/use/\*:$CODECHECK_HOME/lib/\* $MAIN $@ 2>&1 | head --lines $MAXOUTPUTLEN> $BASE/out/$DIR/_compile if [[ ${PIPESTATUS[0]} != 0 ]] ; then mv $BASE/out/$DIR/_compile $BASE/out/$DIR/_errors else @@ -179,6 +218,10 @@ function unittest { timeout -v -s 9 ${TIMEOUT}s java -ea -Djava.awt.headless=true -cp .:$BASE/use/\*:$CODECHECK_HOME/lib/\* org.junit.runner.JUnitCore ${MAIN/.java/} 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_run fi ;; + _PHP) + ulimit -d 100000 -f 1000 -n 100 -v 100000 + timeout -v -s 9 ${TIMEOUT}s phpunit $MAIN 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_run + ;; _Python) ulimit -d 100000 -f 1000 -n 100 -v 100000 timeout -v -s 9 ${TIMEOUT}s python3 -m unittest $MAIN 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_run @@ -187,6 +230,15 @@ function unittest { ulimit -d 100000 -f 1000 -n 100 -v 1000000 timeout -v -s 9 ${TIMEOUT}s racket $MAIN 2>&1 | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_run ;; + _Rust) + rustc -o prog --test $MAIN >> $BASE/out/$DIR/_compile + if [[ ${PIPESTATUS[0]} != 0 ]] ; then + mv $BASE/out/$DIR/_compile $BASE/out/$DIR/_errors + else + ulimit -d 100000 -f 1000 -n 100 -v 100000 + timeout -v -s 9 ${TIMEOUT}s ./prog | head --lines $MAXOUTPUTLEN > $BASE/out/$DIR/_run + fi + ;; esac } @@ -211,5 +263,5 @@ function collect { DIR=$1 shift cd $BASE/$DIR - cp $@ $BASE/out/$DIR + cp --parents $@ $BASE/out/$DIR } diff --git a/conf/application.conf b/conf/application.conf index 67d1fde3..aef20a97 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -52,7 +52,6 @@ play.filters.cors { pathPrefixes = ["/", "null"] } - play.filters.disabled += play.filters.headers.SecurityHeadersFilter play.filters.disabled += play.filters.csrf.CSRFFilter play.filters.disabled += play.filters.hosts.AllowedHostsFilter @@ -87,7 +86,25 @@ play.http.session.maxAge=1d # either in the range 130.211.0.0/22 or 35.191.0.0/16, but "or" is not supported by Play. play.http.forwarded.trustedProxies=["0.0.0.0/0", "::/0"] -com.horstmann.codecheck.repo.ext=/opt/codecheck/ext +# The default is local simulation of storage +com.horstmann.codecheck.storage.local="/opt/codecheck/storage" + +# To use cloud services, +# define com.horstmann.codecheck.storage.type as aws or sql +# For AWS, define +# com.horstmann.codecheck.aws.accessKey +# com.horstmann.codecheck.aws.secretKey +# com.horstmann.codecheck.s3.bucketsuffix +# com.horstmann.codecheck.s3.region +# com.horstmann.codecheck.dynamodb.region +# For a SQL database +# com.horstmann.codecheck.sql.driver +# com.horstmann.codecheck.sql.url + +# This is for running comrun locally com.horstmann.codecheck.comrun.local="/opt/codecheck/comrun" +# For remote comrun execution define +# com.horstmann.codecheck.comrun.remote + include "production" diff --git a/conf/routes b/conf/routes index 82fb6b55..a4ab56fd 100644 --- a/conf/routes +++ b/conf/routes @@ -19,30 +19,44 @@ GET /script/theme-kuroir.js controllers.Assets.at(path="/public/ace", file="th GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) # Web interface -GET /files controllers.Files.filesHTML2(request: Request, repo: String ?= "ext", problem: String, ccid: String ?= null) -GET /files/:problem controllers.Files.filesHTML2(request: Request, repo: String ?= "ext", problem: String, ccid: String ?= null) -GET /files/:repo/:problem controllers.Files.filesHTML2(request: Request, repo: String, problem: String, ccid: String ?= null) +GET /files controllers.Files.filesHTML2(request: Request, repo: String ?= "ext", problem: String) +GET /files/:problem controllers.Files.filesHTML2(request: Request, repo: String ?= "ext", problem: String) +GET /files/:repo/:problem controllers.Files.filesHTML2(request: Request, repo: String, problem: String) POST /checkNJS controllers.Check.checkNJS(request: Request) -GET /tracer/:problem controllers.Files.tracer(request: Request, repo: String ?= "ext", problem: String, ccid: String ?= null) -GET /tracer/:repo/:problem controllers.Files.tracer(request: Request, repo: String, problem: String, ccid: String ?= null) +GET /tracer/:problem controllers.Files.tracer(request: Request, repo: String ?= "ext", problem: String) +GET /tracer/:repo/:problem controllers.Files.tracer(request: Request, repo: String, problem: String) # Used by textbook-problems, cs046 Split, checknjs -GET /fileData controllers.Files.fileData(request: Request, repo: String ?= "ext", problem: String, ccid: String ?= null) +GET /fileData controllers.Files.fileData(request: Request, repo: String ?= "ext", problem: String) +GET /fileData/:problem controllers.Files.fileData(request: Request, repo: String ?= "ext", problem: String) +GET /fileData/:repo/:problem controllers.Files.fileData(request: Request, repo: String, problem: String) +# For pyodide client +GET /setupData/:problem controllers.Check.setupReport(request: Request, repo: String ?= "ext", problem: String) +GET /setupData/:repo/:problem controllers.Check.setupReport(request: Request, repo: String, problem: String) + +# Upload a new problem as a zip file POST /uploadProblem controllers.Upload.uploadProblem(request: Request) +# Upload a new problem as a form POST /uploadFiles controllers.Upload.uploadFiles(request: Request) -GET /private/problem/:problem/:editKey controllers.Upload.editKeySubmit(request: Request, problem: String, editKey: String) +# Get a form for editing an existing problem +GET /private/problem/:problem/:editKey controllers.Upload.editProblem(request: Request, problem: String, editKey: String) # TODO Legacy without /private -GET /edit/:problem/:editKey controllers.Upload.editKeySubmit(request: Request, problem: String, editKey: String) +GET /edit/:problem/:editKey controllers.Upload.editProblem(request: Request, problem: String, editKey: String) +# Upload fixes to an existing problem as a form POST /editedFiles/:problem/:editKey controllers.Upload.editedFiles(request: Request, problem: String, editKey: String) +# Upload fixes to an existing problem as a zip file POST /editedProblem/:problem/:editKey controllers.Upload.editedProblem(request: Request, problem: String, editKey: String) -# Legacy--TODO: Eliminate -GET /codecheck/files controllers.Files.filesHTML(request: Request, repo: String ?= "ext", problem: String, ccu: String ?= null) -GET /codecheck/files/:problem controllers.Files.filesHTML(request: Request, repo: String ?= "ext", problem: String, ccu: String ?= null) -POST /codecheck/check controllers.Check.checkHTML(request: Request) +# Legacy, eliminated +# GET /codecheck/files controllers.Files.filesHTML(request: Request, repo: String ?= "ext", problem: String, ccu: String ?= null) +# GET /codecheck/files/:problem controllers.Files.filesHTML(request: Request, repo: String ?= "ext", problem: String, ccu: String ?= null) +# POST /codecheck/check controllers.Check.checkHTML(request: Request) # Used in Core Java for the Impatient 2e -POST /check controllers.Check.checkHTML(request: Request) +# POST /check controllers.Check.checkHTML(request: Request) + +# Used in Core Java 13e/Core Java for the Impatient 4e +POST /run controllers.Check.run(request: Request) # Assignments @@ -72,15 +86,19 @@ GET /private/viewSubmissions/:assignmentID/:key controllers.Assignment.viewS # Instructor saves assignment POST /saveAssignment controllers.Assignment.saveAssignment(request: Request) +# Instructor saves feedback +POST /saveComment controllers.Assignment.saveComment(request: Request) # Student saves work POST /saveWork controllers.Assignment.saveWork(request: Request) -# LTI Launch (legacy with query param for ID) +# LTI Launch (legacy with query param id) POST /lti/assignment controllers.LTIAssignment.launch(request: Request, assignmentID: String = null) # LTI Launch POST /assignment/:assignmentID controllers.LTIAssignment.launch(request: Request, assignmentID: String) # LTI Launch, in case someone posts a viewAssignment URL instead of cloning it POST /viewAssignment/:assignmentID controllers.LTIAssignment.launch(request: Request, assignmentID: String) +# Bridge (with query param url) +POST /lti/bridge controllers.LTIAssignment.launch(request: Request, assignmentID: String = null) # Student saves work POST /lti/saveWork controllers.LTIAssignment.saveWork(request: Request) diff --git a/project/build.properties b/project/build.properties index f1aa20ce..e9fd4f28 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1,2 +1,2 @@ template.uuid=34fb6e68-1cc6-4f00-8c8f-7ab816ccafc4 -sbt.version=1.4.9 +sbt.version=1.10.5 diff --git a/project/plugins.sbt b/project/plugins.sbt index c194a59e..de790f81 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ -addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8") -addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.2.4") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.9.5") +addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.2.4") \ No newline at end of file diff --git a/public/codecheck.css b/public/codecheck.css index 798fede2..2b8cf7f0 100644 --- a/public/codecheck.css +++ b/public/codecheck.css @@ -49,11 +49,14 @@ } body { - font-family: sans; + font-family: sans-serif; } code, pre { font-family: DejaVuSansMonoSemiCondensed; +} + +pre { font-size: 110%; } @@ -216,4 +219,22 @@ code, pre { .horstmann_rearrange .hc-bad { background: #F4D3DD; +} + +th a { + color: black; + text-decoration: none; + transition: color 0.3s; +} + +th a:hover { + color: grey; +} + +th.ascending a:after { + content: ' △'; +} + +th.descending a:after { + content: ' ▽'; } \ No newline at end of file diff --git a/public/codecheck.js b/public/codecheck.js deleted file mode 100644 index 0f846615..00000000 --- a/public/codecheck.js +++ /dev/null @@ -1,310 +0,0 @@ -document.addEventListener('DOMContentLoaded', function () { - // TODO: Legacy - // For embedding CodeCheck in an iframe - - function getScore() { // TODO: Remove when decommissioning v1 - let repo = document.querySelector('input[name=repo]').getAttribute('value'); - let problem = document.querySelector('input[name=problem]').getAttribute('value'); - let scoreText = document.getElementById('codecheck-submit-response').score; - let correct = 0; - let maxscore = 1; // default maxscore. not 0 to avoid divide by zero - if (scoreText !== undefined && scoreText !== '0' && scoreText.length > 0) { - correct = scoreText.split('/')[0]; - maxscore = scoreText.split('/')[1]; - } - return {correct: correct, errors: 0, maxscore: maxscore, repo: repo, problem: problem}; - } - - function getNumericScore() { - let scoreText = document.getElementById('codecheck-submit-response').score; - let correct = 0; - let maxscore = 1; // default maxscore. not 0 to avoid divide by zero - if (scoreText !== undefined && scoreText !== '0' && scoreText.length > 0) { - correct = scoreText.split('/')[0]; - maxscore = scoreText.split('/')[1]; - } - return correct / maxscore - } - - function restoreStudentWork(studentWork) { - if (studentWork === null) return - for (let i = 0; i < studentWork.length; i++) { - const problemName = studentWork[i].problemName; - const studentCode = studentWork[i].code; - - // Need to get the textarea with the given id, then find the ace editor from there - const editorDiv = document.getElementById(problemName); - const editor = ace.edit(editorDiv); - editor.setValue(studentCode); - } - } - - function getStudentWork() { - let studentWork = []; - let editorDivs = document.getElementsByClassName('editor'); - for (let i = 0; i < editorDivs.length; i++) { - let editor = ace.edit(editorDivs[i]); - if (!editorDivs[i].classList.contains('readonly')) - studentWork.push({problemName: editorDivs[i].getAttribute('id'), code: editor.getValue()}); - } - return studentWork - } - - function receiveMessage(event) { - if (event.data.request) { // It's a response - const request = event.data.request - if (request.query === 'retrieve') { // LTIHub v2 - restoreStudentWork(event.data.param) - } - } - else if (event.data.query === 'docHeight') { // All of these are LTIHub v1 - const body = document.body - const html = document.documentElement; - const fudge = 50; - const height = Math.max( body.scrollHeight, body.offsetHeight, - html.clientHeight, html.scrollHeight, html.offsetHeight ) + fudge; - document.body.style.overflow = 'auto'; - event.source.postMessage({docHeight: height, request: event.data}, '*' ); - } else if (event.data.query === 'restoreState') { - document.body.style.overflow = 'auto'; - restoreStudentWork(event.data.state) - } else if (event.data.query === 'getContent') { - const studentWork = getStudentWork() - const response = { request: event.data, score: getScore(), state: studentWork }; - event.source.postMessage(response, event.origin); - } - } - - window.addEventListener('message', receiveMessage, false); - - // Set up Ace editors - - let files = document.getElementsByClassName('file'); - for (let i = 0; i < files.length; i++) { - let editorDivs = files[i].getElementsByClassName('editor'); - let editors = []; - for (let k = 0; k < editorDivs.length; k++) - editors.push(ace.edit(editorDivs[k])); - for (let k = 0; k < editors.length; k++) { - let divId = editorDivs[k].getAttribute('id') - let session = editors[k].getSession() - if (divId.indexOf('.java-')!=-1) { - session.setMode('ace/mode/java'); - } else if(divId.indexOf('.cpp-')!=-1 || divId.indexOf('.c-')!=-1 || divId.indexOf('.h-')!=-1) { - session.setMode('ace/mode/c_cpp'); - } else if(divId.indexOf('.py-')!=-1) { - session.setMode('ace/mode/python'); - } else { - session.setMode('ace/mode/text'); - } - editors[k].setOption('autoScrollEditorIntoView', true); - editors[k].setOption('displayIndentGuides', false); - // TODO: These give error "misspelled option" - // editors[k].setOption('enableBasicAutocompletion', true); - // editors[k].setOption('enableLiveAutocompletion', false); - // editors[k].setOption('enableSnippets', true); - editors[k].setOption('tabSize', 3); - editors[k].setOption('useWorker', true); - editors[k].setOption('highlightActiveLine', false); - editors[k].setOption('highlightGutterLine', false); - editors[k].setOption('showFoldWidgets', false); - editors[k].setOption('newLineMode', 'unix'); - editors[k].setOption('fontSize', '1em'); - editors[k].setOption('scrollSpeed', 0); - - if(editorDivs[k].classList.contains('readonly')){ - editors[k].setReadOnly(true); - // https://stackoverflow.com/questions/32806060/is-there-a-programmatic-way-to-hide-the-cursor-in-ace-editor - editors[k].renderer.$cursorLayer.element.style.display = 'none' - editors[k].setTheme('ace/theme/kuroir'); - } else { - editors[k].setTheme('ace/theme/chrome'); - } - } - - let update = function() { - let totalLines = 0; - for (let k = 0; k < editors.length; k++) { - let editorSession = editors[k].getSession() - editorSession.clearAnnotations() - editorSession.setOption('firstLineNumber', totalLines + 1); - let lines = editors[k].getSession().getDocument().getLength(); - editorDivs[k].style.height = (editors[k].renderer.lineHeight * lines) + "px"; - editors[k].resize(); - let aceScroller = editorDivs[k].getElementsByClassName('ace_scroller')[0] - aceScroller.style.bottom = '0px' - // this is the scrolled area, not the scroll bar - // we are hiding the scroll bars in css, but ace doesn't seem to take that into account - totalLines += lines; - } - } - for (let k = 0; k < editors.length; k++) { - editors[k].on('change', update); - } - window.addEventListener('resize', function() { setTimeout(update, 1000)}) - update(); - } - - // Form submission - - function inIframe() { - try { - return window.self !== window.top - } catch (e) { - return true - } - } - - function highlightLine(file, line, message) { - let totalLines = 0 - let fileDiv = document.getElementById(file); - if (fileDiv == null) return // This happens if there is an error in a tester - let editorDivs = fileDiv.getElementsByClassName('editor') - let editors = [] - for (let k = 0; k < editorDivs.length; k++) - editors.push(ace.edit(editorDivs[k])) - for (let k = 0; k < editors.length; k++) { - let editorSession = editors[k].getSession(); - let length = editorSession.getDocument().getLength() - totalLines += length - if (totalLines >= line) { - let annotations = editorSession.getAnnotations() - annotations.push({ - row: line - (totalLines - length) - 1, // ace editor lines are 0-indexed - text: message, - type: "error" - }) - editorSession.setAnnotations(annotations); - return - } - } - } - - function clearErrorAnnotations () { - let editorDivs = document.getElementsByClassName('editor'); - for (let k = 0; k < editorDivs.length; k++) - ace.edit(editorDivs[k]).getSession().clearAnnotations() - } - - function questionID(form) { - let inputs = form.getElementsByTagName('input'); - let problem = '' - let repo = '' - for (const input of inputs) { - let name = input.getAttribute('name') - if (name === 'problem') - problem = input.getAttribute('value') - else if (name === 'repo') - repo = input.getAttribute('value') - } - if (repo === 'wiley') return problem - else return window.location.href - } - - let docHeight = 0 - function sendDocHeight() { - window.scrollTo(0, 0) - const SEND_DOCHEIGHT_DELAY = 100 - setTimeout(() => { - let newDocHeight = document.documentElement.scrollHeight + document.documentElement.offsetTop - if (docHeight != newDocHeight) { - docHeight = newDocHeight - const data = { query: 'docHeight', param: { docHeight } } - window.parent.postMessage(data, '*' ) - } - }, SEND_DOCHEIGHT_DELAY) - } - - let form = document.getElementsByTagName('form')[0] - let qid = questionID(form) - - function successfulSubmission(data) { - let submitButton = document.getElementById('submit'); - let codecheckSubmitResponse = document.getElementById('codecheck-submit-response') - submitButton.removeAttribute('disabled'); - codecheckSubmitResponse.innerHTML = data['report'] - if (!inIframe()) { // No download button in iframe (Engage) - if(/(Version)\/(\d+)\.(\d+)(?:\.(\d+))?.*Safari\//.test(navigator.userAgent)) { - codecheckSubmitResponse.innerHTML += '
Download not supported on Safari. Use Firefox or Chrome!
' - } - else { - codecheckSubmitResponse.innerHTML += - '
' - + '
' - } - } - codecheckSubmitResponse.score = data['score'] - - clearErrorAnnotations(); - if ('errors' in data) { - for (let i = 0; i < data['errors'].length; i++) { - let error = data['errors'][i]; - highlightLine(error['file'], error['line'], error['message']); } - } - - if (inIframe()) { - const param = { qid, state: getStudentWork(), score: getNumericScore() } - const data = { query: 'send', param } - console.log('Posting to parent', data) - window.parent.postMessage(data, '*' ) - } - } - - - form.addEventListener('submit', function(e) { - e.preventDefault(); - let submitButton = document.getElementById('submit'); - submitButton.setAttribute('disabled', 'disabled'); - clearErrorAnnotations(); - let codecheckSubmitResponse = document.getElementById('codecheck-submit-response') - codecheckSubmitResponse.innerHTML = '

Submitting...

' - let params = {} - let inputs = form.getElementsByTagName('input'); - for (let i = 0; i < inputs.length; i++) { - let name = inputs[i].getAttribute('name') - if (name !== null) - params[name] = inputs[i].getAttribute('value') - } - - let files = document.getElementsByClassName('file'); - for (let i = 0; i < files.length; i++) { - let allContent = ""; - let editorDivs = files[i].getElementsByClassName('editor'); - for (let k = 0; k < editorDivs.length; k++) { - if (k > 0) allContent += "\n" - allContent += ace.edit(editorDivs[k]).getValue(); - } - let filename = files[i].getAttribute('id'); - params[filename] = allContent - } - - let xhr = new XMLHttpRequest() - xhr.timeout = 300000 // 5 minutes - xhr.open('POST', '/checkNJS'); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.onload = function() { - if (xhr.status === 200) successfulSubmission(JSON.parse(xhr.responseText)) - else { - submitButton.removeAttribute('disabled'); - codecheckSubmitResponse.innerHTML = - '
Error Status: ' + xhr.status + ' ' + xhr.statusText + '
\n' + - '
Error Response: ' + xhr.responseText + '
\n'; - } - } - xhr.send(JSON.stringify(params)) - }) - - if (inIframe()) { - document.body.style.height = '100%' - document.body.style.overflow = 'hidden' - // ResizeObserver did not work - const mutationObserver = new MutationObserver(sendDocHeight); - mutationObserver.observe(document.documentElement, { childList: true, subtree: true }) - - const data = { query: 'retrieve', param: { qid } } - console.log('Posting to parent', data) - window.parent.postMessage(data, '*' ) - } -}) - - diff --git a/public/codecheck2.js b/public/codecheck2.js index 488bf8a7..02659684 100644 --- a/public/codecheck2.js +++ b/public/codecheck2.js @@ -1,3 +1,5 @@ +// Uses postData from util.js + window.horstmann_config = { inIframe: function () { try { @@ -7,35 +9,9 @@ window.horstmann_config = { } }, - postData: async function(url = '', data = {}) { - const response = await fetch(url, { - method: 'POST', // *GET, POST, PUT, DELETE, etc. - mode: 'cors', // no-cors, *cors, same-origin - cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached - credentials: 'include', // include, *same-origin, omit - headers: { - 'Content-Type': 'application/json' - }, - redirect: 'follow', // manual, *follow, error - referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url - body: JSON.stringify(data) // body data type must match "Content-Type" header - }); - if (response.ok) - return await response.json() // parses JSON response into native JavaScript objects - else { - const body = await response.text() - if (response.status === 500) console.log(body) - const msg = - response.status === 500 ? 'Server error' : - response.status === 400 ? `Error: ${body}` : // Bad reqest - `Error ${response.status} ${response.statusText}: ${body}` - throw new Error(msg) - } - }, - score_change_listener: (element, state, score) => { if ('lti' in horstmann_config) { - horstmann_config.postData(horstmann_config.lti.sendURL, { + postData(horstmann_config.lti.sendURL, { state, score, lis_outcome_service_url: horstmann_config.lti.lis_outcome_service_url, @@ -46,9 +22,15 @@ window.horstmann_config = { } else if (horstmann_config.inIframe()) { const qid = horstmann_config.getInteractiveId(element) // TODO should be done by caller - const param = { state, score, qid } - const data = { query: 'send', param } - window.parent.postMessage(data, '*' ) + // const param = { state, score, qid } + // const message = { query: 'send', param } + const message = { + subject: 'SPLICE.reportScoreAndState', + message_id: horstmann_config.generateUUID(), + score, + state + } + window.parent.postMessage(message, '*' ) } }, @@ -80,7 +62,7 @@ window.horstmann_config = { retrieve_state: async (element, callback) => { if ('lti' in horstmann_config) { try { - let response = await horstmann_config.postData(horstmann_config.lti.retrieveURL, { + let response = await postData(horstmann_config.lti.retrieveURL, { submissionID: horstmann_config.lti.submissionID }) callback(element, response.state) @@ -90,16 +72,20 @@ window.horstmann_config = { } } else if (horstmann_config.inIframe()) { - const nonce = horstmann_config.generateUUID() - horstmann_config.nonceMap[nonce] = callback - const qid = horstmann_config.getInteractiveId(element) // TODO should be done by caller - const param = { qid } - const data = { query: 'retrieve', param, nonce } - window.parent.postMessage(data, '*') + const message_id = horstmann_config.generateUUID() + horstmann_config.nonceMap[message_id] = callback + // const qid = horstmann_config.getInteractiveId(element) // TODO should be done by caller + // const param = { qid } + // const message = { query: 'retrieve', param, nonce } + const message = { + subject: 'SPLICE.getState', + message_id + } + window.parent.postMessage(message, '*') const MESSAGE_TIMEOUT = 5000 setTimeout(() => { - if ((nonce in horstmann_config.nonceMap)) { - delete horstmann_config.nonceMap[nonce] + if ((message_id in horstmann_config.nonceMap)) { + delete horstmann_config.nonceMap[message_id] callback(element, null) } }, MESSAGE_TIMEOUT) @@ -115,14 +101,11 @@ let _ = x => x document.addEventListener('DOMContentLoaded', function () { if (horstmann_config.inIframe()) { function receiveMessage(event) { - if (event.data.request) { // It's a response - const request = event.data.request - if (request.query === 'retrieve') { - if (request.nonce in horstmann_config.nonceMap) { - // If not, already timed out - horstmann_config.nonceMap[request.nonce](null, event.data.param) - delete horstmann_config.nonceMap[request.nonce] - } + if (event.data.subject === 'SPLICE.getState.response') { + if (event.data.message_id in horstmann_config.nonceMap) { + // If not, already timed out + horstmann_config.nonceMap[event.data.message_id](null, event.data.state) + delete horstmann_config.nonceMap[event.data.message_id] } } } @@ -136,21 +119,30 @@ document.addEventListener('DOMContentLoaded', function () { setTimeout(() => { let newDocHeight = document.documentElement.scrollHeight + document.documentElement.offsetTop if (docHeight != newDocHeight) { - docHeight = newDocHeight - const data = { query: 'docHeight', param: { docHeight } } - window.parent.postMessage(data, '*' ) + // docHeight = newDocHeight + // const message = { query: 'docHeight', param: { docHeight } } + const message = { + subject: 'lti.frameResize', + message_id: horstmann_config.generateUUID(), + height: newDocHeight, + width: document.documentElement.scrollWidth + document.documentElement.offsetLeft + } + window.parent.postMessage(message, '*' ) } }, SEND_DOCHEIGHT_DELAY) } - document.body.style.height = '100%' - document.body.style.overflow = 'hidden' + // document.body.style.height = '100%' + // document.body.style.overflow = 'hidden' // ResizeObserver did not work const mutationObserver = new MutationObserver(sendDocHeight); mutationObserver.observe(document.documentElement, { childList: true, subtree: true }) - const data = { query: 'retrieve', param: { } } - window.parent.postMessage(data, '*' ) + const message = { + subject: 'SPLICE.getState', + message_id: horstmann_config.generateUUID() + } + window.parent.postMessage(message, '*' ) } else { // TODO: Ugly? // set in download.js, used when initializing the UI diff --git a/public/editAssignment.js b/public/editAssignment.js index f090e555..53cc4a87 100644 --- a/public/editAssignment.js +++ b/public/editAssignment.js @@ -12,15 +12,34 @@ window.addEventListener('DOMContentLoaded', () => { } } return result - } - + } + + let field = document.querySelector('#deadlineDate') + deadlineDate.addEventListener('input', function() { + let date = field.value + document.getElementById('deadlineDate').value = date + const local = document.getElementById('deadlineLocal') + const utc = document.getElementById('deadlineUTC') + if (date === '') { + local.style.display = "none" + utc.style.display = "none" + } else { + let date = document.getElementById('deadlineDate').value + let deadlineLocal = new Date(date) + let deadlineUTC = new Date(deadlineLocal).toUTCString() + local.textContent = deadlineLocal + utc.textContent = deadlineUTC + " (UTC)" + local.style.display = "block" + utc.style.display = "block" + } + }) + const responseDiv = document.getElementById('response') if ('problems' in assignment) document.getElementById('problems').value = format(assignment.problems) if (askForDeadline) { if ('deadline' in assignment) { // an ISO 8601 string like "2020-12-01T23:59:59Z" - document.getElementById('deadlineDate').value = assignment.deadline.substring(0, 10) - document.getElementById('deadlineTime').value = assignment.deadline.substring(11, 16) + document.getElementById('deadlineDate').value = assignment.deadline } } else { const deadlineDiv = document.getElementById('deadlineDiv') @@ -35,10 +54,9 @@ window.addEventListener('DOMContentLoaded', () => { } if (askForDeadline) { - const deadlineDate = document.getElementById('deadlineDate').value - const deadlineTime = document.getElementById('deadlineTime').value - if (deadlineDate != '' && deadlineTime != '') - request.deadline = deadlineDate + 'T' + deadlineTime + ':59Z' + if(document.getElementById('deadlineDate').value !== ''){ + request.deadline = new Date(document.getElementById('deadlineDate').value).toISOString() + } } submitButton.disabled = true responseDiv.style.display = 'none' diff --git a/public/editProblem.js b/public/editProblem.js new file mode 100644 index 00000000..34ccac73 --- /dev/null +++ b/public/editProblem.js @@ -0,0 +1,46 @@ +window.addEventListener('DOMContentLoaded', () => { + +let i = 1 +let done = false +while (!done) { + const deleteButton = document.getElementById('delete' + i) + if (deleteButton == null) + done = true + else { + deleteButton.addEventListener('click', + function() { + document.getElementById('filename' + i).setAttribute('value', '') + document.getElementById('contents' + i).innerHTML = '' + document.getElementById('item' + i).style.display = 'none' + }) + i++ + } +} +let fileIndex = i +document.getElementById('addfile').addEventListener('click', + function() { + let fileDiv = document.createElement('div') + fileDiv.setAttribute('id', 'item' + fileIndex) + fileDiv.innerHTML = '

File name:

' + let addFile = document.getElementById('addfilecontainer') + addFile.parentNode.insertBefore(fileDiv, addFile) + + document.getElementById('delete' + fileIndex).addEventListener('click', + function() { + document.getElementById('filename' + fileIndex).setAttribute('value', '') + document.getElementById('contents' + fileIndex).innerHTML = '' + document.getElementById('item' + fileIndex).style.display = 'none' + }) + fileIndex++ +}) + +document.getElementById('upload').disabled = document.getElementById('file').files.length === 0 + +document.getElementById('file').addEventListener('change', function() { + document.getElementById('upload').disabled = document.getElementById('file').files.length === 0 +}) + +}) \ No newline at end of file diff --git a/public/horstmann_codecheck.css b/public/horstmann_codecheck.css index b7975a22..5812563c 100644 --- a/public/horstmann_codecheck.css +++ b/public/horstmann_codecheck.css @@ -96,3 +96,10 @@ font-size: 0.8em; } +/* Use the same color as in chrome theme for highlighting: https://github.com/cayhorstmann/codecheck2/issues/67 */ +.ace-kuroir .ace_marker-layer .ace_selection { + background: rgb(181, 213, 255); +} + +/* https://github.com/ajaxorg/ace/issues/2963 */ +.ace_text-input {position:absolute!important} diff --git a/public/horstmann_codecheck.js b/public/horstmann_codecheck.js index ee4b74c4..4ab8f742 100644 --- a/public/horstmann_codecheck.js +++ b/public/horstmann_codecheck.js @@ -1,10 +1,12 @@ +// Uses postData, createButton from util.js + window.horstmann_codecheck = { setup: [], }; if (typeof ace !== 'undefined') { ace.config.set('themePath', 'script'); } -window.addEventListener('load', function () { +window.addEventListener('load', async function () { 'use strict'; function createRearrange(fileName, setup) { @@ -140,17 +142,17 @@ window.addEventListener('load', function () { function makeTile(contents, isFixed) { let tileDiv = createTile() - let text - if (typeof contents === 'object') { - text = contents.text - if ('code' in contents) { - codeMap.set(tileDiv, contents.code) - tileDiv.classList.add('pseudo') + let text + if (typeof contents === 'object') { + text = contents.text + if ('code' in contents) { + codeMap.set(tileDiv, contents.code) + tileDiv.classList.add('pseudo') } - } - else { - text = contents - } + } + else { + text = contents + } if (isFixed) { tileDiv.classList.add('fixed') tileDiv.setAttribute('draggable', false); @@ -172,7 +174,7 @@ window.addEventListener('load', function () { } tileDiv.textContent = strippedText setIndent(tileDiv, minIndent) - } + } } else { tileDiv.textContent = text @@ -367,11 +369,12 @@ window.addEventListener('load', function () { drag = undefined insertDroppedTileRight(tileDiv) }) - - for (const fixed of setup.fixed) - left.appendChild(makeTile(fixed, true)) - for (const tile of setup.tiles) - right.appendChild(makeTile(tile, false)) + if ('fixed' in setup) + for (const fixed of setup.fixed) + left.appendChild(makeTile(fixed, true)) + if ('tiles' in setup) + for (const tile of setup.tiles) + right.appendChild(makeTile(tile, false)) both.classList.add('horstmann_rearrange') return both @@ -379,16 +382,16 @@ window.addEventListener('load', function () { function getState() { const leftTiles = [] - const group = [] + let group = [] for (const tile of left.children) { if (tile.classList.contains('fixed')) { leftTiles.push(group) - group.length = 0 + group = [] } else { - let state = { text: tile.textContent, indent: tile.indent } - let code = codeMap.get(tile) - if (code !== undefined) state.code = code + let state = { text: tile.textContent, indent: tile.indent } + let code = codeMap.get(tile) + if (code !== undefined) state.code = code group.push(state) } } @@ -398,14 +401,14 @@ window.addEventListener('load', function () { left: leftTiles, right: [...right.children].map(tile => { let code = codeMap.get(tile) - if (code !== undefined) return { text: tile.textContent, code } - else return tile.textContent + if (code !== undefined) return { text: tile.textContent, code } + else return tile.textContent }) } } function restoreState(state) { - codeMap.clear() + codeMap.clear() let i = 0 let leftTiles = [...left.children] for (const tile of leftTiles) { @@ -414,7 +417,8 @@ window.addEventListener('load', function () { i++ for (let j = group.length - 1; j >= 0; j--) { const newTile = makeTile(group[j], false) - left.insertBefore(newTile) + left.insertBefore(newTile, tile) + setIndent(newTile, tile.group[i].indent) } } else @@ -423,6 +427,7 @@ window.addEventListener('load', function () { for (const t of state.left[i]) { const newTile = makeTile(t, false) left.appendChild(newTile) + setIndent(newTile, t.indent) } right.innerHTML = '' for (const t of state.right) { @@ -528,17 +533,13 @@ window.addEventListener('load', function () { }; if (readonly) { + editor.setTheme('ace/theme/kuroir'); editor.setReadOnly(true); + // At one time, the cursor was completely blocked, but it seems reasonable to allow copying the code // https://stackoverflow.com/questions/32806060/is-there-a-programmatic-way-to-hide-the-cursor-in-ace-editor - editor.renderer.$cursorLayer.element.style.display = 'none' - editor.setTheme('ace/theme/kuroir'); - let lines = editor.getSession().getDocument().getLength(); - editor.setOptions({ - minLines: lines, - maxLines: lines - }); + //editor.renderer.$cursorLayer.element.style.display = 'none' // https://github.com/ajaxorg/ace/issues/266 - editor.textInput.getElement().tabIndex = -1 + //editor.textInput.getElement().tabIndex = -1 } else { editor.setTheme('ace/theme/chrome'); @@ -692,7 +693,7 @@ window.addEventListener('load', function () { let editors = new Map() function restoreState(dummy, state) { // TODO: Eliminate dummy - if (state === null) return; + if (state === null || state === undefined) return; // TODO Can it be null??? let work = state.work if ('studentWork' in state) { // TODO: Legacy state work = {} @@ -779,7 +780,9 @@ window.addEventListener('load', function () { if (fileName === 'Input') { submitButtonLabel = _('Run') // Don't provide an edit box if no input required - if (setup.requiredFiles['Input'][0].trim().length > 0) + const fileSetup = setup.requiredFiles['Input'] + const editors = 'editors' in fileSetup ? fileSetup.editors : fileSetup + if (editors[0].trim().length > 0) inputPresent = true; else { let hiddenInput = document.createElement('input') @@ -806,7 +809,8 @@ window.addEventListener('load', function () { let editorDiv = document.createElement('div') editorDiv.classList.add('editor') - editorDiv.textContent = setup.useFiles[fileName].replace(/\r?\n$/, ''); + let text = setup.useFiles[fileName].replace(/\r?\n$/, '') + editorDiv.textContent = text let editor = ace.edit(editorDiv) let fileObj = document.createElement('div') @@ -816,35 +820,71 @@ window.addEventListener('load', function () { filenameDiv.textContent = directoryPrefix + fileName fileObj.appendChild(filenameDiv) fileObj.appendChild(editorDiv) - setupAceEditor(editorDiv, editor, fileName, /*readonly*/ true) + const MAX_LINES = 200 + const lines = text.split(/\n/).length + editor.setOption('maxLines', Math.min(lines, MAX_LINES)) + setupAceEditor(editorDiv, editor, fileName, /*readonly*/ true) form.appendChild(fileObj) + + if (lines > MAX_LINES) { + const viewButton = createButton('hc-command', _('Expand'), function() { + if (editor.getOption('maxLines') > MAX_LINES) { + editor.setOption('maxLines', MAX_LINES) + editor.resize() + viewButton.innerHTML = _('Expand') + } + else { + editor.setOption('maxLines', lines) + editor.resize() + viewButton.innerHTML = _('Collapse') + } + }) + form.appendChild(viewButton) + } } - submitButton = document.createElement('span') - submitButton.textContent = submitButtonLabel - submitButton.classList.add('hc-button') - submitButton.classList.add('hc-start') - submitButton.tabIndex = 0 + submitButton = createButton('hc-start', submitButtonLabel, async function() { + response.textContent = 'Submitting...' + let params = {} + // Hidden inputs + for (const input of form.getElementsByTagName('input')) { + let name = input.getAttribute('name') + if (name !== null) + params[name] = input.getAttribute('value') + } + + for (const [filename, editor] of editors) { + editor.clearErrorAnnotations() + params[filename] = editor.getText() + } + + submitButton.classList.add('hc-disabled') + if (downloadButton !== undefined) downloadButton.style.display = 'none' + try { + const result = await postData(setup.url, params) + successfulSubmission(result) + } catch (e) { + response.innerHTML = `
Error: ${e.message}
` + } + submitButton.classList.remove('hc-disabled'); + }) + submitDiv.appendChild(submitButton); - let resetButton = document.createElement('span') - resetButton.textContent = _('Reset') - resetButton.classList.add('hc-button') - resetButton.classList.add('hc-start') - resetButton.tabIndex = 0 + let resetButton = createButton('hc-start', _('Reset'), function() { + restoreState(element, initialState) + element.correct = 0; + response.innerHTML = '' + if (downloadButton !== undefined) downloadButton.style.display = 'none' + }) submitDiv.appendChild(resetButton); if ('download' in horstmann_config) { - downloadButton = document.createElement('span') - downloadButton.textContent = _('Download') - downloadButton.classList.add('hc-button') - downloadButton.classList.add('hc-start') - downloadButton.tabIndex = 0 - downloadButton.style.display = 'none' - downloadButton.addEventListener('click', () => { - horstmann_config.download('data:application/octet-stream;base64,' + downloadButton.data.zip, downloadButton.data.metadata.ID + '.signed.zip', 'application/octet-stream') + downloadButton = createButton('hc-start', _('Download'), () => { + horstmann_config.download('data:application/octet-stream;base64,' + downloadButton.data.zip, downloadButton.data.metadata.ID + '.signed.zip', 'application/octet-stream') }) + downloadButton.style.display = 'none' submitDiv.appendChild(downloadButton); } @@ -864,17 +904,9 @@ window.addEventListener('load', function () { response.classList.add('codecheck-submit-response') form.appendChild(response) - prepareSubmit(setup.url); - element.appendChild(form) let initialState = getState(); - resetButton.addEventListener('click', function() { - restoreState(element, initialState) - element.correct = 0; - response.innerHTML = '' - if (downloadButton !== undefined) downloadButton.style.display = 'none' - }) } function getState() { @@ -911,48 +943,14 @@ window.addEventListener('load', function () { } if ('errors' in data) { - for (const error of data.errors) - editors.get(error['file']).errorAnnotation(error['line'], error['message']) + for (const error of data.errors) { + const editor = editors.get(error['file']) + // TODO: Non-editable files are not in editors. Would be nice to annotate anyway + if (editor !== undefined) editor.errorAnnotation(error['line'], error['message']) + } } } - function prepareSubmit(url) { - submitButton.addEventListener('click', function() { - response.textContent = 'Submitting...' - let params = {} - // Hidden inputs - for (const input of form.getElementsByTagName('input')) { - let name = input.getAttribute('name') - if (name !== null) - params[name] = input.getAttribute('value') - } - - for (const [filename, editor] of editors) { - editor.clearErrorAnnotations() - params[filename] = editor.getText() - } - - submitButton.classList.add('hc-disabled') - if (downloadButton !== undefined) downloadButton.style.display = 'none' - - let xhr = new XMLHttpRequest() - xhr.withCredentials = true - xhr.timeout = 300000 // 5 minutes - xhr.open('POST', url); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.onload = function() { - submitButton.classList.remove('hc-disabled'); - if (xhr.status === 200) - successfulSubmission(JSON.parse(xhr.responseText)) - else - response.innerHTML = - '
Error Status: ' + xhr.status + ' ' + xhr.statusText + '
\n' + - '
Error Response: ' + xhr.responseText + '
\n'; - } - xhr.send(JSON.stringify(params)) - }) - } - // .................................................................. // Start of initElement @@ -965,7 +963,26 @@ window.addEventListener('load', function () { // Start of event listener let elements = document.getElementsByClassName('horstmann_codecheck') - for (let index = 0; index < elements.length; index++) - initElement(elements[index], - window.horstmann_codecheck.setup[index]) + for (let index = 0; index < elements.length; index++) { + const setup = window.horstmann_codecheck.setup[index] + /* + Required properties: + repo + problem + Optional: + url + requiredFiles (if not present, obtained with fileData) + useFiles + order + prefix + */ + if ('requiredFiles' in setup) + initElement(elements[index], setup) + else { + const origin = new URL(window.location.href).origin + const response = await fetch(`${origin}/fileData?repo=${setup.repo}&problem=${setup.problem}`) + const data = await response.json() + initElement(elements[index], { url: `${origin}/checkNJS`, ...data, ...setup }) + } + } }); diff --git a/public/images/favicon.png b/public/images/favicon.png deleted file mode 100644 index c7d92d2a..00000000 Binary files a/public/images/favicon.png and /dev/null differ diff --git a/public/javascripts/hello.js b/public/javascripts/hello.js deleted file mode 100644 index 209fbee5..00000000 --- a/public/javascripts/hello.js +++ /dev/null @@ -1,3 +0,0 @@ -if (window.console) { - console.log("Welcome to your Play application's JavaScript!"); -} \ No newline at end of file diff --git a/public/receiveMessage.js b/public/receiveMessage.js index 3f7706d3..73cbe705 100644 --- a/public/receiveMessage.js +++ b/public/receiveMessage.js @@ -113,7 +113,7 @@ if (window.self !== window.top) { }) element = interactiveElements[0] sendDocHeight() - document.body.style.overflow = 'hidden' + // document.body.style.overflow = 'hidden' // ResizeObserver did not work const mutationObserver = new MutationObserver(sendDocHeight); mutationObserver.observe(element === undefined ? document.body : element, { childList: true, subtree: true }) diff --git a/public/resources/codecheck.h b/public/resources/codecheck.h index 2822fae2..ff239885 100644 --- a/public/resources/codecheck.h +++ b/public/resources/codecheck.h @@ -3,6 +3,8 @@ #include #include +using namespace std; + namespace codecheck { void print(int x); @@ -10,7 +12,7 @@ namespace codecheck { void print(double x); void print(float x); void print(bool x); - void print(std::string x); + void print(string x); void print(const char* x); bool eq(int x, int y); @@ -18,19 +20,19 @@ namespace codecheck { bool eq(double x, double y); bool eq(float x, float y); bool eq(bool x, bool y); - bool eq(std::string x, std::string y); + bool eq(string x, string y); bool eq(const char* x, const char* y); - template void print(std::vector xs) { - std::cout << "{"; + template void print(vector xs) { + cout << "{"; for (int i = 0; i < xs.size(); i++) { - if (i > 0) { std::cout << ","; } std::cout << " "; + if (i > 0) { cout << ","; } cout << " "; print(xs[i]); } - std::cout << " }"; + cout << " }"; } - template bool eq(std::vector xs, std::vector ys) { + template bool eq(vector xs, vector ys) { if (xs.size() != ys.size()) return false; for (int i = 0; i < xs.size(); i++) { if (!eq(xs[i], ys[i])) return false; @@ -39,8 +41,8 @@ namespace codecheck { } template void compare(T x, T y) { - print(x); std::cout << std::endl; - print(y); std::cout << std::endl; - std::cout << std::boolalpha << eq(x, y) << std::endl; + print(x); cout << endl; + print(y); cout << endl; + cout << boolalpha << eq(x, y) << endl; } } diff --git a/public/resources/codecheck.js b/public/resources/codecheck.js deleted file mode 100644 index 1e29343b..00000000 --- a/public/resources/codecheck.js +++ /dev/null @@ -1,53 +0,0 @@ -// From https://github.com/angular/angular.js/blob/master/src/Angular.js -function isDate(value) { - return toString.call(value) === '[object Date]'; -} -function isRegExp(value) { - return toString.call(value) === '[object RegExp]'; -} -function isFunction(value) {return typeof value === 'function';} - -function deepEquals(o1, o2) { - if (o1 === o2) return true; - if (o1 === null || o2 === null) return false; - // eslint-disable-next-line no-self-compare - if (o1 !== o1 && o2 !== o2) return true; // NaN === NaN - var t1 = typeof o1, t2 = typeof o2, length, key, keySet; - if (t1 === t2 && t1 === 'object') { - if (Array.isArray(o1)) { - if (!Array.isArray(o2)) return false; - if ((length = o1.length) === o2.length) { - for (key = 0; key < length; key++) { - if (!deepEquals(o1[key], o2[key])) return false; - } - return true; - } - } else if (isDate(o1)) { - if (!isDate(o2)) return false; - return deepEquals(o1.getTime(), o2.getTime()); - } else if (isRegExp(o1)) { - if (!isRegExp(o2)) return false; - return o1.toString() === o2.toString(); - } else { - if (Array.isArray(o2) || isDate(o2) || isRegExp(o2)) return false; - keySet = createMap(); - for (key in o1) { - if (key.charAt(0) === '$' || isFunction(o1[key])) continue; - if (!deepEquals(o1[key], o2[key])) return false; - keySet[key] = true; - } - for (key in o2) { - if (!(key in keySet) && - key.charAt(0) !== '$' && - isDefined(o2[key]) && - !isFunction(o2[key])) return false; - } - return true; - } - } - return false; -} - -exports.deepEquals = deepEquals - - diff --git a/public/resources/codecheck.properties b/public/resources/codecheck.properties deleted file mode 100644 index 0972d7f3..00000000 --- a/public/resources/codecheck.properties +++ /dev/null @@ -1 +0,0 @@ -version=1502151133 diff --git a/public/stylesheets/main.css b/public/stylesheets/main.css deleted file mode 100644 index e69de29b..00000000 diff --git a/public/uploadProblem.html b/public/uploadProblem.html index b142fb01..0688d1cc 100644 --- a/public/uploadProblem.html +++ b/public/uploadProblem.html @@ -1,3 +1,4 @@ + @@ -5,7 +6,7 @@ Upload Content - +

File name:

@@ -48,4 +49,4 @@ }) - \ No newline at end of file + diff --git a/public/uploadSingleFile.html b/public/uploadSingleFile.html index f30d8030..4e9d3e23 100644 --- a/public/uploadSingleFile.html +++ b/public/uploadSingleFile.html @@ -5,7 +5,7 @@ Upload Content - +

File name:

@@ -42,4 +42,4 @@

Alternatively, upload a zip file:

- \ No newline at end of file + diff --git a/public/main.js b/public/util.js similarity index 91% rename from public/main.js rename to public/util.js index 1aac9128..07515e8f 100644 --- a/public/main.js +++ b/public/util.js @@ -1,4 +1,4 @@ -async function postData(url = '', data = {}) { +async function postData(url = '', data = {}, timeout = 90000) { const response = await fetch(url, { method: 'POST', // *GET, POST, PUT, DELETE, etc. mode: 'cors', // no-cors, *cors, same-origin @@ -9,7 +9,8 @@ async function postData(url = '', data = {}) { }, redirect: 'follow', // manual, *follow, error referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url - body: JSON.stringify(data) // body data type must match "Content-Type" header + body: JSON.stringify(data), // body data type must match "Content-Type" header + signal: AbortSignal.timeout(timeout) }); if (response.ok) return await response.json() // parses JSON response into native JavaScript objects diff --git a/public/viewSubmissions.js b/public/viewSubmissions.js index 2729b5b3..ea4ad9e4 100644 --- a/public/viewSubmissions.js +++ b/public/viewSubmissions.js @@ -1,94 +1,296 @@ - /* - * TODO: Sort table - */ +/* + Willmaster Table Sort + Version 1.1 + August 17, 2016 + Updated GetDateSortingKey() to correctly sort two-digit months and days numbers with leading 0. + Version 1.0, July 3, 2011 + + Will Bontrager + https://www.willmaster.com/ + Copyright 2011,2016 Will Bontrager Software, LLC + + This software is provided "AS IS," without + any warranty of any kind, without even any + implied warranty such as merchantability + or fitness for a particular purpose. + Will Bontrager Software, LLC grants + you a royalty free license to use or + modify this software provided this + notice appears on all copies. +*/ +var tableIDValue = 'submissions'; +var tableLastSortedColumn = -1; + +function sort_table() { + var sortColumn = parseInt(arguments[0]); + var type = arguments.length > 1 ? arguments[1] : 'T'; + var dateAndTimeFormat = arguments.length > 2 ? arguments[2] : ''; + var table = document.getElementById(tableIDValue); + var tbody = table.getElementsByTagName('tbody')[0]; + var rows = tbody.getElementsByTagName('tr'); + var arrayOfRows = new Array(); + type = type.toUpperCase(); + dateAndTimeFormat = dateAndTimeFormat.toLowerCase(); + + + // Add the appropriate class to the sorted column header + var headers = table.querySelectorAll('th'); + headers.forEach(function (header, index) { + if (index !== sortColumn) { + header.className = 'unsorted'; + } else { + } + }); + var sortedHeader = headers[sortColumn]; + if (sortedHeader.className == 'unsorted') { + sortedHeader.className = 'ascending' + } + else if (sortedHeader.className == 'ascending') { + sortedHeader.className = 'descending' + } + else { + sortedHeader.className = 'ascending' + } + + for (var i = 0, len = rows.length; i < len; i++) { + arrayOfRows[i] = new Object(); + arrayOfRows[i].oldIndex = i; + var celltext = rows[i].getElementsByTagName('td')[sortColumn].innerHTML.replace(/[^>]*>/g, ''); + + if (type == 'D') { + // Access the anchor tag within the cell + var anchor = + rows[i].getElementsByTagName('td')[sortColumn].getElementsByTagName('a')[0]; + var celltext = anchor ? anchor.textContent : ''; // Get the text content of the anchor + arrayOfRows[i].value = get_date_and_time_sorting_key(dateAndTimeFormat, celltext); + } else { + var re = type == 'N' ? /[^\.\-\+\d]/g : /[^a-zA-Z0-9]/g; + arrayOfRows[i].value = celltext + .replace(re, '') + .substr(0, 25) + .toLowerCase(); + } + } + + if (sortColumn == tableLastSortedColumn) { + arrayOfRows.reverse(); + } else { + tableLastSortedColumn = sortColumn; + switch (type) { + case 'N': + arrayOfRows.sort(compare_row_of_numbers); + break; + case 'D': + arrayOfRows.sort(compare_row_of_numbers); + break; + case 'S': + // Checks if the first character of the first value is a number, and sorts rows accordingly. + // If it's a number, sorts numerically; otherwise, sorts using text comparison. + if (isNaN(arrayOfRows[0].value[0])) { + arrayOfRows.sort(compare_row_of_text); + } + else { + arrayOfRows.sort(compare_row_of_numbers); + break; + } + default: + arrayOfRows.sort(compare_row_of_text); + } + } + + var newtableBody = document.createElement('tbody'); + for (var i = 0, len = arrayOfRows.length; i < len; i++) { + newtableBody.appendChild(rows[arrayOfRows[i].oldIndex].cloneNode(true) + ); + } + table.replaceChild(newtableBody, tbody); +} + +function compare_row_of_text(a, b) { + var aval = a.value; + var bval = b.value; + return aval == bval ? 0 : aval > bval ? 1 : -1; +} + +function compare_row_of_numbers(a, b) { + var aval = /\d/.test(a.value) ? parseFloat(a.value) : 0; + var bval = /\d/.test(b.value) ? parseFloat(b.value) : 0; + return aval == bval ? 0 : aval > bval ? 1 : -1; +} + +function get_date_and_time_sorting_key(format, text) { + if (format.length < 1) { + return ''; + } + format = format.toLowerCase(); + text = text.toLowerCase(); + text = text.replace(/^[^a-z0-9]*/, ''); + text = text.replace(/[^a-z0-9]*$/, ''); + if (text.length < 1) { + return ''; + } + text = text.replace(/[^a-z0-9]+/g, ','); + var date = text.split(','); + if (date.length < 7) { + return ''; + } + var d = 0, + m = 0, + y = 0; + for (var i = 0; i < 7; i++) { + var ts = format.substr(i, 1); + if (ts == 'd') { + d = date[i]; + } else if (ts == 'm') { + m = date[i]; + } else if (ts == 'y') { + y = date[i]; + } else if (ts == 'h') { + h = date[i]; + } else if (ts == 't') { + t = date[i]; + } else if (ts == 's') { + s = date[i]; + } else if (ts == 'p') { + p = date[i]; + } + } + d = d.replace(/^0/, ''); + if (d < 10) { + d = '0' + d; + } + m = m.replace(/^0/, ''); + if (m < 10) { + m = '0' + m; + } + y = parseInt(y); + if (y < 100) { + y = parseInt(y) + 2000; + } + if (p == 'pm') { + h = parseInt(h) + 12; + } + if (h < 10) { + h = '0' + h; + } + return ( '' + String(y) + '' + String(m) + '' + String(d) + '' + String(h) + '' + String(t) + '' + String(s)); +} + window.addEventListener('DOMContentLoaded', () => { -// https://stackoverflow.com/questions/15547198/export-html-table-to-csv - function download_table_as_csv(table_id) { - // Select rows from table_id - var rows = document.querySelectorAll('table#' + table_id + ' tr'); - // Construct csv - var csv = []; - for (var i = 0; i < rows.length; i++) { - var row = [], cols = rows[i].querySelectorAll('td, th'); - for (var j = 0; j < cols.length; j++) { - // Clean innertext to remove multiple spaces and jumpline (break csv) - var data = cols[j].innerText.replace(/(\r\n|\n|\r)/gm, '').replace(/(\s\s)/gm, ' ') - // Escape double-quote with double-double-quote (see https://stackoverflow.com/questions/17808511/properly-escape-a-double-quote-in-csv) - data = data.replace(/"/g, '""'); - // Push escaped string - row.push('"' + data + '"'); - } - csv.push(row.join(';')); + // https://stackoverflow.com/questions/15547198/export-html-table-to-csv + function download_table_as_csv(table_id) { + // Select rows from table_id + var rows = document.querySelectorAll('table#' + table_id + ' tr'); + // Construct csv + var csv = []; + for (var i = 0; i < rows.length; i++) { + var row = [], cols = rows[i].querySelectorAll('td, th'); + for (var j = 0; j < cols.length; j++) { + // Clean innertext to remove multiple spaces and jumpline (break csv) + var data = cols[j].innerText.replace(/(\r\n|\n|\r)/gm, '').replace(/(\s\s)/gm, ' ') + // Escape double-quote with double-double-quote (see https://stackoverflow.com/questions/17808511/properly-escape-a-double-quote-in-csv) + data = data.replace(/"/g, '""'); + // Push escaped string + row.push('"' + data + '"'); + } + csv.push(row.join(';')); + } + var csv_string = csv.join('\n'); + // Download it + var filename = 'export_' + table_id + '_' + new Date().toLocaleDateString() + '.csv'; + var link = document.createElement('a'); + link.style.display = 'none'; + link.setAttribute('target', '_blank'); + link.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv_string)); + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + const body = document.querySelector('body') + append(body, 'h1', 'Assignment Submissions') + const buttonDiv = append(body, 'div') + if (Object.keys(submissionData).length === 0) { + append(body, 'p', 'No submissions yet') + } else { + append(body, 'h2', 'Submissions') + buttonDiv.appendChild(createButton('hc-command', 'Download CSV', () => download_table_as_csv('submissions'))) + const jsonDownloadButton = document.createElement('a') + jsonDownloadButton.classList.add('hc-button', 'hc-command') + jsonDownloadButton.setAttribute('href', allSubmissionsURL) + jsonDownloadButton.setAttribute('target', '_blank') + jsonDownloadButton.textContent = 'Download JSON' + buttonDiv.appendChild(jsonDownloadButton) + const table = append(body, 'table') + table.id = 'submissions' + let tr = append(table, 'tr') + + // Creating headers + const headers = [ + { + text: 'Your Student Info', + href: 'javascript:sort_table(0,"S");', + }, + { text: 'Opaque ID', href: 'javascript:sort_table(1,"T");' }, + { text: 'Score', href: 'javascript:sort_table(2,"N");' }, + { + text: 'Submitted At', + href: 'javascript:sort_table(3,"D", "mdyhtsp");', + }, + ]; + + for (const header of headers) { + const th = append(tr, 'th'); + th.setAttribute('title', 'Click to sort') + if (header.text === 'Your Student Info') { + const a = document.createElement('a'); + a.href = header.href; + a.textContent = header.text; + th.appendChild(a); + th.style.display = 'none'; + } else { + const a = document.createElement('a'); + a.href = header.href; + a.textContent = header.text; + th.appendChild(a); } - var csv_string = csv.join('\n'); - // Download it - var filename = 'export_' + table_id + '_' + new Date().toLocaleDateString() + '.csv'; - var link = document.createElement('a'); - link.style.display = 'none'; - link.setAttribute('target', '_blank'); - link.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv_string)); - link.setAttribute('download', filename); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } - - function sorted(a) { - return [...a].sort() - } - - /* - TODO: Sort table by clicking on column... - */ - - const body = document.querySelector('body') - append(body, 'h1', 'Assignment Submissions') - const buttonDiv = append(body, 'div') - if (Object.keys(submissionData).length === 0) { - append(body, 'p', 'No submissions yet') - } else { - append(body, 'h2', 'Submissions') - buttonDiv.appendChild(createButton('hc-command', 'Download CSV', () => download_table_as_csv('submissions'))) - const jsonDownloadButton = document.createElement('a') - jsonDownloadButton.classList.add('hc-button', 'hc-command') - jsonDownloadButton.setAttribute('href', allSubmissionsURL) - jsonDownloadButton.setAttribute('target', '_blank') - jsonDownloadButton.textContent = 'Download JSON' - buttonDiv.appendChild(jsonDownloadButton) - const table = append(body, 'table') - table.id = 'submissions' - let tr = append(table, 'tr') - append(tr, 'th', 'Your Student Info').style.display = 'none' - append(tr, 'th', 'Opaque ID') - append(tr, 'th', 'Score') - append(tr, 'th', 'Submitted At') + } + + // Create tbody + const tbody = document.createElement('tbody'); + table.appendChild(tbody); + + // Iterating through submissionData for (const submission of submissionData) { - tr = append(table, 'tr') - append(tr, 'td').style.display = 'none' - append(tr, 'td', submission.opaqueID).classList.add('ccid') - append(tr, 'td', percent(submission.score)) - const a = document.createElement('a') - a.href = submission.viewURL - a.target = '_blank' - a.textContent = new Date(submission.submittedAt).toLocaleString() - append(tr, 'td', a) + tr = append(tbody, 'tr'); + append(tr, 'td').style.display = 'none'; + append(tr, 'td', submission.opaqueID).classList.add('ccid'); + append(tr, 'td', percent(submission.score)); + const a = document.createElement('a'); + a.href = submission.viewURL; + a.target = '_blank'; + a.textContent = new Date(submission.submittedAt).toLocaleString(); + append(tr, 'td', a); } - const rosterDiv = append(body, 'div') - append(rosterDiv, 'h2', 'Roster information') - append(rosterDiv, 'p', 'Enter lines with CodeCheck student IDs and your corresponding student IDs/names here') - const rosterTextArea = append(rosterDiv, 'textarea') - rosterTextArea.rows = submissionData.length - rosterTextArea.cols = 80 - rosterDiv.appendChild(createButton('hc-command', 'Add to Table', () => { - for (line of rosterTextArea.value.split("\n").map(s => s.trim()).filter(s => s != '')) { - let ccid = line.split(/\s+/)[0] - let info = line.substring(ccid.length).trim() - for (const row of table.querySelectorAll('tr')) { - row.children[0].style.display = '' - if (row.children[1].textContent === ccid) - row.children[0].textContent = info - } - } - })) - } + + const rosterDiv = append(body, 'div'); + append(rosterDiv, 'h2', 'Roster information'); + append(rosterDiv, 'p', 'Enter lines with CodeCheck student IDs and your corresponding student IDs/names here'); + const rosterTextArea = append(rosterDiv, 'textarea'); + rosterTextArea.rows = submissionData.length; + rosterTextArea.cols = 80; + rosterDiv.appendChild(createButton('hc-command', 'Add to table', () => { + for (line of rosterTextArea.value.split('\n').map((s) => s.trim()).filter((s) => s != '')) { + let ccid = line.split(/\s+/)[0]; + let info = line.substring(ccid.length).trim(); + for (const row of table.querySelectorAll('tr')) { + row.children[0].style.display = ''; + if (row.children[1].textContent === ccid) + row.children[0].textContent = info; + } + } + }) + ); + } }) \ No newline at end of file diff --git a/public/workAssignment.js b/public/workAssignment.js index 9d98c03d..66e496d0 100644 --- a/public/workAssignment.js +++ b/public/workAssignment.js @@ -58,28 +58,28 @@ window.addEventListener('DOMContentLoaded', () => { updateScoreInElementText(document.getElementById('heading'), result, explanation) } - function adjustDocHeight(iframe, request) { - console.log({frame: iframeKey.get(iframe), oldHeight: iframe.scrollHeight, newHeight: request.param.docHeight }) - const newHeight = request.param.docHeight; + function adjustDocHeight(iframe, newHeight) { + // console.log({frame: iframeKey.get(iframe), oldHeight: iframe.scrollHeight, newHeight }) + if ('chrome' in window) { // https://stackoverflow.com/questions/4565112/javascript-how-to-find-out-if-the-user-browser-is-chrome + const CHROME_FUDGE = 32 // to prevent scroll bars in Chrome + newHeight += CHROME_FUDGE + } if (iframe.scrollHeight < newHeight) iframe.style.height = newHeight + 'px' } - function restoreStateOfProblem(iframe, request) { + function getStateOfProblem(iframe) { let key = iframeKey.get(iframe) - if (key in work.problems) { - iframe.contentWindow.postMessage({ request, param: work.problems[key].state }, '*'); - } else { - iframe.contentWindow.postMessage({ request, param: null }, '*') - } - updateScoreDisplay(); + updateScoreDisplay(); // TODO: Why? + if (key in work.problems) return work.problems[key].state + else return null // TODO: Why not undefined } - async function sendScoreAndState(iframe, request) { + async function sendScoreAndState(iframe, score, state) { if (!assignment.isStudent) return // Viewing as instructor let key = iframeKey.get(iframe) // Don't want qid which is also in request.param - work.problems[key] = { score: request.param.score, state: request.param.state } + work.problems[key] = { score, state } updateScoreDisplay(); try { responseDiv.textContent = '' @@ -102,6 +102,11 @@ window.addEventListener('DOMContentLoaded', () => { let select = undefined function initializeProblemSelectorUI() { + if (assignment.problems.length === 1) { + document.getElementById('abovebuttons').style.display = 'none' + return + } + buttonDiv = document.createElement('div') buttonDiv.id = 'buttons' document.body.appendChild(buttonDiv) @@ -118,6 +123,7 @@ window.addEventListener('DOMContentLoaded', () => { } function addProblemSelector(index, title) { + if (assignment.problems.length === 1) return const number = '' + (index + 1) if (useTitles) { const option = document.createElement('option') @@ -129,15 +135,15 @@ window.addEventListener('DOMContentLoaded', () => { } function activateProblemSelection() { + if (useTitles) { + select.disabled = false + buttonDiv.children[1].classList.remove('hc-disabled') + } else if (assignment.problems.length > 1) { + for (const b of buttonDiv.children) + b.classList.remove('hc-disabled') + } savedCopyCheckbox.checked = true - if (useTitles) { - select.disabled = false - buttonDiv.children[1].classList.remove('hc-disabled') - } else { - for (const b of buttonDiv.children) - b.classList.remove('hc-disabled') - } - const tab = 'tab' in work ? work.tab : 0 + const tab = 'tab' in work ? work.tab : 0 setTimeout(() => selectProblem(tab), 1000) } @@ -155,7 +161,7 @@ window.addEventListener('DOMContentLoaded', () => { if (index < 0 || index >= assignment.problems.length) return if (useTitles) { select.selectedIndex = index - } else { + } else if (assignment.problems.length > 1) { for (let i = 0; i < buttonDiv.children.length; i++) if (i === index) buttonDiv.children[i].classList.add('active') @@ -177,7 +183,7 @@ window.addEventListener('DOMContentLoaded', () => { function updateScoreInProblemSelector(index, score) { if (useTitles) { updateScoreInElementText(select.children[index], score) - } else { + } else if (assignment.problems.length > 1) { updateScoreInElementText(buttonDiv.children[index], score) } } @@ -190,19 +196,26 @@ window.addEventListener('DOMContentLoaded', () => { window.history.replaceState(null, '', '/') for (const e of document.getElementsByClassName('ccid')) - if (studentID !== '') e.textContent = studentID - else - e.parentNode.style.display = 'none' window.addEventListener("message", event => { let iframe = sendingIframe(event) if (event.data.query === 'docHeight') - adjustDocHeight(iframe, event.data) - else if (event.data.query === 'retrieve') - restoreStateOfProblem(iframe, event.data) + adjustDocHeight(iframe, event.data.param.docHeight) + else if (event.data.query === 'retrieve') { + const state = getStateOfProblem(iframe) + iframe.contentWindow.postMessage({ request: event.data, param: state }, '*') + } else if (event.data.query === 'send') - sendScoreAndState(iframe, event.data) + sendScoreAndState(iframe, event.data.param.score, event.data.param.state) + else if (event.data.subject === 'lti.frameResize') + adjustDocHeight(iframe, event.data.height) + else if (event.data.subject === 'SPLICE.reportScoreAndState') + sendScoreAndState(iframe, event.data.score, event.data.state) + else if (event.data.subject === 'SPLICE.getState') { + const state = getStateOfProblem(iframe) + iframe.contentWindow.postMessage({ subject: 'SPLICE.getState.response', message_id: event.data.message_id, state }, '*') + } }, false); /* @@ -231,7 +244,19 @@ window.addEventListener('DOMContentLoaded', () => { addProblemSelector(i, assignment.problems[i].title) } + if (assignment.noHeader) { + document.getElementsByTagName('details')[0].style.display = 'none' + } + if (assignment.isStudent) { + if('deadline' in assignment){ + document.getElementById('deadline').textContent = "Deadline:" + let deadline = assignment.deadline + let deadlineUTC = new Date(deadline).toUTCString() + let deadlineLocal = new Date(Date.parse(deadline)) + document.getElementById('deadlineLocal').textContent = deadlineLocal + document.getElementById('deadlineUTC').textContent = deadlineUTC + " (UTC)" + } if (lti === undefined) { document.getElementById('studentLTIInstructions').style.display = 'none' const returnToWorkURLSpan = document.getElementById('returnToWorkURL') @@ -299,5 +324,37 @@ window.addEventListener('DOMContentLoaded', () => { activateProblemSelection() document.getElementById('studentInstructions').style.display = 'none' document.getElementById('studentLTIInstructions').style.display = 'none' - } + } + + if (assignment.isStudent) { + if (assignment?.comment !== "") + document.getElementById('student_comment').textContent = assignment.comment + else + document.getElementById('student_comment_div').style.display = 'none' + } + else if (assignment.saveCommentURL) { + document.getElementById('instructor_comment').textContent = assignment?.comment + const submitButton = createButton('hc-command', 'Save Comment', async () => { + let request = { + assignmentID: assignment.assignmentID, + workID: assignment.workID, // undefined when isStudent + comment: document.getElementById('instructor_comment').value, + } + + submitButton.disabled = true + responseDiv.style.display = 'none' + try { + await postData(assignment.saveCommentURL, request) + } catch (e) { + responseDiv.textContent = e.message + responseDiv.style.display = 'block' + } + submitButton.disabled = false + }) + document.getElementById('instructor_comment_div').appendChild(submitButton) + } + else { + document.getElementById('instructor_comment_div').style.display = 'none' + } + }) diff --git a/receiveMessage.js b/receiveMessage.js index 0d4bfa2b..33a97afb 100644 --- a/receiveMessage.js +++ b/receiveMessage.js @@ -1,98 +1,32 @@ -/* +// This file is included by the activities on www.interactivities.ws - Protocol: +// The following starts out with splice-iframe.js, and then translates VitalSource EPUB to SPLICE -data. - query (request from parent to child) 'docHeight', 'getContent', 'restoreState' (LTIHub v1) - (request from child to parent) 'docHeight', 'send', 'retrieve' (LTIHub v2) - request (response) the request +window.addEventListener('load', event => { + const scores = {} + const states = {} + let callbacks = undefined + let docHeight = 0 - state (restoreState request from parent to child, v1) - state (getContent response from child to parent, v1) - - nonce (request from child to parent) a nonce to be returned with - the response, for non-void requests ('retrieve', v2) - - docHeight (docHeight request, response from child to parent, v1) - - score (getContent response from child to parent, v1) - - param (request or response) parameter object (v2) - - -v2 details: - -Always from child to parent - -{ query: 'docHeight', param: { docHeight: ... } } - No qid because applies to whole frame - No response - -{ query: 'send', param: { qid: ..., state: ..., score: ... }} - Score between 0 and 1 - No response + const sendDocHeight = () => { + if (window.self === window.top) return // not iframe -{ query: 'retrieve', param: { qid: ... } } - Response: { request: ..., param: state } - -TODO: Why not param: { state: ..., score: ... } - If we want that, must adapt workAssignment.js, receiveMessage.js, codecheck.js - -*/ - -if (window.self !== window.top) { // iframe - if (!('EPUB' in window)) - window.EPUB = {} - if (!('Education' in window.EPUB)) { - window.EPUB.Education = { - nonceMap: {}, - retrieveCallback: undefined, // LTIHub v1 - retrieve: (request, callback) => { - window.EPUB.Education.retrieveCallback = callback // LTIHub v1 - if ('stateToRestore' in window.EPUB.Education) { // LTIHub v1, restore data already arrived - callback({ data: [ { data: window.EPUB.Education.stateToRestore } ] }) - delete window.EPUB.Education.stateToRestore - return - } - // Register callback - const nonce = generateUUID() - window.EPUB.Education.nonceMap[nonce] = callback - if (window.EPUB.Education.version !== 1) { // LTIHub v1 - // Pass request and nonce to parent - // TODO: VitalSource format - const qid = request.filters[0].activityIds[0] - const param = { qid } - const data = { query: 'retrieve', param, nonce } - window.parent.postMessage(data, '*' ) - } - const MESSAGE_TIMEOUT = 5000 - setTimeout(() => { - if ('stateToRestore' in window.EPUB.Education) { // LTIHub v1, restore data already arrived and delivered - delete window.EPUB.Education.stateToRestore - return - } - - if (!(nonce in window.EPUB.Education.nonceMap)) return - delete window.EPUB.Education.nonceMap[nonce] - // TODO: VitalSource format - callback({ data: [ { data: null } ] }) - }, MESSAGE_TIMEOUT) - }, - send: (request, callback) => { - if (window.EPUB.Education.version === 1) return // LTIHub v1 - // TODO: VitalSource format - const param = { state: request.data[0].state.data, score: request.data[0].results[0].score, qid: request.data[0].activityId } - const data = { query: 'send', param } + window.scrollTo(0, 0) + const SEND_DOCHEIGHT_DELAY = 100 + setTimeout(() => { + let newDocHeight = document.body.scrollHeight + document.body.offsetTop + if (docHeight != newDocHeight) { + docHeight = newDocHeight + const data = { subject: 'lti.frameResize', message_id: generateUUID(), height: docHeight } window.parent.postMessage(data, '*' ) - sendDocHeight() - }, - } + if (window.SPLICE.logging) console.log('postMessage to parent', data) + } + }, SEND_DOCHEIGHT_DELAY) } // https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript - - function generateUUID() { // Public Domain/MIT + const generateUUID = () => { // Public Domain/MIT var d = new Date().getTime() var d2 = (performance && performance.now && (performance.now() * 1000)) || 0 // Time in microseconds since page-load or 0 if unsupported return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { @@ -108,87 +42,103 @@ if (window.self !== window.top) { // iframe }) } - let element = undefined - let docHeight = 0 - - function sendDocHeight() { - window.scrollTo(0, 0) - const SEND_DOCHEIGHT_DELAY = 100 - if (window.EPUB.Education.version === 1) return // TODO - setTimeout(() => { - const container = element === undefined ? document.body : element.closest('li').parentNode - // When using container = document.documentElement, the document grows without bound - // TODO: Why does this work in codecheck.js but not here? - let newDocHeight = container.scrollHeight + container.offsetTop - if (docHeight != newDocHeight) { - docHeight = newDocHeight - const data = { query: 'docHeight', param: { docHeight } } - window.parent.postMessage(data, '*' ) - } - }, SEND_DOCHEIGHT_DELAY) + if (!('SPLICE' in window)) { + window.SPLICE = { + logging: true, + getScore: (location, callback) => { + if (callbacks === undefined) { + callback(states[location]) + } else { + callbacks.push({location, callback}) + } + }, + reportScoreAndState: (location, score, state) => { + scores[location] = score + states[location] = state + if (window.self === window.top) return // not iframe + let averageScore = 0 + let n = 0 + for (const location in scores) { + averageScore += scores[location]; + n++ + } + if (n > 0) averageScore /= n + const message = { subject: 'SPLICE.reportScoreAndState', message_id: generateUUID(), score: averageScore, state: states } + window.parent.postMessage(message, '*' ) + if (window.SPLICE.logging) console.log('postMessage to parent', message) + }, + sendEvent: (location, name, data) => { + const message = { subject: 'SPLICE.sendEvent', message_id: generateUUID(), name, data } + window.parent.postMessage(message, '*' ) + if (window.SPLICE.logging) console.log('postMessage to parent', message) + } + } } - - window.addEventListener('load', event => { - const interactiveElements = [...document.querySelectorAll('div, ol')]. - filter(e => { - const ty = e.tagName - const cl = e.getAttribute('class') - return cl && (ty === 'div' && cl.indexOf('horstmann_') == 0 || ty === 'ol' && (cl.indexOf('multiple-choice') == 0 || cl.indexOf('horstmann_ma') == 0)) - }) - element = interactiveElements[0] - sendDocHeight() - document.body.style.overflow = 'hidden' - // ResizeObserver did not work - const mutationObserver = new MutationObserver(sendDocHeight); - mutationObserver.observe(element === undefined ? document.body : element, { childList: true, subtree: true }) - }) - window.addEventListener("message", event => { + if (window.self !== window.top) { // iframe + const message = { subject: 'SPLICE.getState', message_id: generateUUID() } + window.parent.postMessage(message, '*' ) + if (window.SPLICE.logging) console.log('postMessage to parent', message) + callbacks = [] + const MESSAGE_TIMEOUT = 5000 + setTimeout(() => { + if (callbacks === undefined) return + for (const { location, callback } of callbacks) { + scores[location] = 0 + states[location] = undefined + callback(undefined, { code: 'timeout' }) + } + callbacks = undefined + }, MESSAGE_TIMEOUT) + } + + window.addEventListener('message', event => { if (!(event.data instanceof Object)) return - if ('request' in event.data) { // It's a response - const request = event.data.request - if (request.query === 'retrieve') { // LTIHub v2 - const state = event.data.param - // TODO Old VitalSource API - // let state = response.data[0].data - const arg = { data: [ { data: state } ] } - if (request.nonce in window.EPUB.Education.nonceMap) { - // If not, already timed out - window.EPUB.Education.nonceMap[request.nonce](arg) - delete window.EPUB.Education.nonceMap[request.nonce] + if (!(event.data.subject instanceof String && event.data.subject.startsWith('SPLICE.'))) return + if (event.data.subject.endsWith('response')) { + if (window.SPLICE.logging) console.log('postmessage response', event) + if (event.data.subject === 'SPLICE.getState.response') { + if (callbacks === undefined) return // Already timed out + for (const location in event.data.state) { + scores[location] = 0 + states[location] = event.data.state[location] } + + for (const { location, callback } of callbacks) { + callback(states[location], event.data.error) + } + callbacks = undefined } - // Handle other responses - } else { // It's a request - if (event.data.query === 'docHeight') { // LTIHub v1 - const docHeight = document.body.children[0].scrollHeight - document.documentElement.style.height = docHeight + 'px' - document.body.style.height = docHeight + 'px' - document.body.style.overflow = 'auto' - let response = { request: event.data, docHeight } - event.source.postMessage(response, '*' ) - window.EPUB.Education.version = 1 - } - else if (event.data.query === 'getContent') { // LTIHub v1 - const docHeight = document.body.children[0].scrollHeight - document.documentElement.style.height = docHeight + 'px' - document.body.style.height = docHeight + 'px' - const id = element.closest('li').id - const score = { correct: Math.min(element.correct, element.maxscore), errors: element.errors, maxscore: element.maxscore, activity: id } - let response = { request: event.data, score: score, state: element.state } - event.source.postMessage(response, '*' ) - } else if (event.data.query === 'restoreState') { // LTIHub v1 - window.EPUB.Education.stateToRestore = event.data.state - /* - It is possible that the element already made a - retrieve request (which goes unanswered by the parent). - */ - if (window.EPUB.Education.retrieveCallback !== undefined) { // retrieve request already made - window.EPUB.Education.retrieve(undefined, window.EPUB.Education.retrieveCallback) - // delete window.EPUB.Education.retrieveCallback - // Not deleting--in the instructor view assignments, called more than once - } - } + // Handle other responses } + // TODO: SPLICE messages from children }, false) -} + + sendDocHeight() + document.body.style.overflow = 'hidden' + // ResizeObserver did not work + const mutationObserver = new MutationObserver(sendDocHeight); + mutationObserver.observe(document.body, { childList: true, subtree: true }) + + // Translating EPUB to SPLICE + if (!('EPUB' in window)) + window.EPUB = {} + if (!('Education' in window.EPUB)) { + window.EPUB.Education = { + retrieve: (request, callback) => { + const location = request.filters[0].activityIds[0] + SPLICE.getState(location, (location, state) => { + callback({ data: [ { data: state } ] }) + }) + }, + send: (request, callback) => { + const location = request.data[0].activityId + const score = request.data[0].results[0].score + const state = request.data[0].state.data + SPLICE.reportScoreAndState(location, score, state) + }, + } + } + +}) + diff --git a/samples/bash/call1/main.sh b/samples/bash/call1/main.sh new file mode 100644 index 00000000..92704be0 --- /dev/null +++ b/samples/bash/call1/main.sh @@ -0,0 +1,7 @@ +##CALL sh +##CALL txt +function countFiles { + ##HIDE + ls *.$1 2>/dev/null | wc -l + ##EDIT ... +} diff --git a/samples/bash/run1/main.sh b/samples/bash/run1/main.sh new file mode 100644 index 00000000..5616245a --- /dev/null +++ b/samples/bash/run1/main.sh @@ -0,0 +1,3 @@ +##HIDE +ls +##EDIT # Write a command to list all files in the current directory diff --git a/samples/bash/run1/premain.sh b/samples/bash/run1/premain.sh new file mode 100644 index 00000000..9d778f0d --- /dev/null +++ b/samples/bash/run1/premain.sh @@ -0,0 +1,6 @@ +##HIDE +mkdir work +cd work +touch peter +touch paul +touch mary diff --git a/samples/bash/run2/main.sh b/samples/bash/run2/main.sh new file mode 100644 index 00000000..13fa4b65 --- /dev/null +++ b/samples/bash/run2/main.sh @@ -0,0 +1,3 @@ +##HIDE +echo NAME is $NAME +##EDIT echo ... diff --git a/samples/bash/run2/premain.sh b/samples/bash/run2/premain.sh new file mode 100644 index 00000000..66e553fd --- /dev/null +++ b/samples/bash/run2/premain.sh @@ -0,0 +1 @@ +export NAME=Fred diff --git a/samples/bash/run3/main.sh b/samples/bash/run3/main.sh new file mode 100644 index 00000000..426a27d3 --- /dev/null +++ b/samples/bash/run3/main.sh @@ -0,0 +1,5 @@ +##ARGS peter paul mary +##HIDE +cat $@ +##EDIT # Print the contents of all files given as command line arguments +##EDIT ... diff --git a/samples/bash/run3/premain.sh b/samples/bash/run3/premain.sh new file mode 100644 index 00000000..134b5814 --- /dev/null +++ b/samples/bash/run3/premain.sh @@ -0,0 +1,3 @@ +echo Peter > peter +echo Paul > paul +echo Mary > mary diff --git a/samples/bash/run4/main.sh b/samples/bash/run4/main.sh new file mode 100644 index 00000000..4f43fe10 --- /dev/null +++ b/samples/bash/run4/main.sh @@ -0,0 +1,5 @@ +##OUT work/out +##HIDE +cat peter paul mary > out +##EDIT # Concatenate the files peter, paul, and mary to the file out +##EDIT ... diff --git a/samples/bash/run4/premain.sh b/samples/bash/run4/premain.sh new file mode 100644 index 00000000..1be618a2 --- /dev/null +++ b/samples/bash/run4/premain.sh @@ -0,0 +1,5 @@ +mkdir work +cd work +echo Peter > peter +echo Paul > paul +echo Mary > mary diff --git a/samples/bash/sub1/main.sh b/samples/bash/sub1/main.sh new file mode 100644 index 00000000..bc1c38a7 --- /dev/null +++ b/samples/bash/sub1/main.sh @@ -0,0 +1,4 @@ +FILE=peter ##SUB paul;mary +##HIDE +cat $FILE +##EDIT # Print the contents of the file stored in FILE diff --git a/samples/bash/sub1/premain.sh b/samples/bash/sub1/premain.sh new file mode 100644 index 00000000..134b5814 --- /dev/null +++ b/samples/bash/sub1/premain.sh @@ -0,0 +1,3 @@ +echo Peter > peter +echo Paul > paul +echo Mary > mary diff --git a/samples/bash/tester1/count_txt_files.sh b/samples/bash/tester1/count_txt_files.sh new file mode 100644 index 00000000..8b9fd1cd --- /dev/null +++ b/samples/bash/tester1/count_txt_files.sh @@ -0,0 +1,4 @@ +##HIDE +ls *.txt 2>/dev/null | wc -l +##EDIT # Print the number of files with extension .txt +##EDIT ... diff --git a/samples/bash/tester1/tester.sh b/samples/bash/tester1/tester.sh new file mode 100644 index 00000000..5735b4d1 --- /dev/null +++ b/samples/bash/tester1/tester.sh @@ -0,0 +1,9 @@ +./count_txt_files.sh +echo Expected: 0 +touch peter.txt +./count_txt_files.sh +echo Expected: 1 +touch paul.txt +touch mary.txt +./count_txt_files.sh +echo Expected: 3 diff --git a/samples/java/example10/Numbers.java b/samples/java/example10/Numbers.java new file mode 100644 index 00000000..910cf9fb --- /dev/null +++ b/samples/java/example10/Numbers.java @@ -0,0 +1,13 @@ +//SOLUTION +public class Numbers +{ +//CALL 3, 4 +//CALL HIDDEN -3, 3 +//CALL 3, 0 +public double average(int x, int y) + { + //HIDE + return 0.5 * (x + y); + //SHOW // Compute the average of x and y + } +} diff --git a/samples/java/example11/Numbers.java b/samples/java/example11/Numbers.java new file mode 100644 index 00000000..4c072e3c --- /dev/null +++ b/samples/java/example11/Numbers.java @@ -0,0 +1,11 @@ +//SOLUTION +public class Numbers +{ +//CALL HIDDEN -3, 3 +public double average(int x, int y) + { + //HIDE + return 0.5 * (x + y); + //SHOW // Compute the average of x and y + } +} diff --git a/samples/java/example12/Numbers.java b/samples/java/example12/Numbers.java new file mode 100644 index 00000000..b85d3402 --- /dev/null +++ b/samples/java/example12/Numbers.java @@ -0,0 +1,14 @@ +//SOLUTION +public class Numbers +{ +//CALL HIDDEN 3, 4 +//CALL HIDDEN -3, 3 +//CALL HIDDEN 3, 0 +//CALL 4, 0 +public double average(int x, int y) + { + //HIDE + return 0.5 * (x + y); + //SHOW // Compute the average of x and y + } +} diff --git a/samples/java/example13/Numbers.java b/samples/java/example13/Numbers.java new file mode 100644 index 00000000..cdfe5e38 --- /dev/null +++ b/samples/java/example13/Numbers.java @@ -0,0 +1,5 @@ +//SOLUTION +public class Numbers +{ + public int square(int n) { return n * n; } +} diff --git a/samples/java/example13/NumbersTest.java b/samples/java/example13/NumbersTest.java new file mode 100644 index 00000000..1dfc6326 --- /dev/null +++ b/samples/java/example13/NumbersTest.java @@ -0,0 +1,24 @@ +//HIDDEN +import org.junit.Test; +import org.junit.Assert; + +public class NumbersTest +{ + @Test public void testNegative() + { + Numbers numbers = new Numbers(); + Assert.assertEquals(9, numbers.square(-3)); + } + + @Test public void testPositive() + { + Numbers numbers = new Numbers(); + Assert.assertEquals(9, numbers.square(3)); + } + + @Test public void testZero() + { + Numbers numbers = new Numbers(); + Assert.assertEquals(0, numbers.square(0)); + } +} diff --git a/samples/java/example13/NumbersTester.java b/samples/java/example13/NumbersTester.java new file mode 100644 index 00000000..9937f5b9 --- /dev/null +++ b/samples/java/example13/NumbersTester.java @@ -0,0 +1,12 @@ +//HIDDEN +public class NumbersTester +{ + public static void main(String[] args) + { + Numbers nums = new Numbers(); + System.out.println(nums.square(3)); + System.out.println("Expected: 9"); + System.out.println(nums.square(-3)); + System.out.println("Expected: 9"); + } +} diff --git a/samples/java/example14/index.html b/samples/java/example14/index.html new file mode 100644 index 00000000..17b3d8dc --- /dev/null +++ b/samples/java/example14/index.html @@ -0,0 +1 @@ +

Provide a JUnit test class BankTest with three test methods, each of which tests a different method of the Bank class in Chapter 5.

\ No newline at end of file diff --git a/samples/java/example14/q.properties b/samples/java/example14/q.properties new file mode 100644 index 00000000..235c2156 --- /dev/null +++ b/samples/java/example14/q.properties @@ -0,0 +1,8 @@ +#Do not remove +#uni:link (comma-separated) +question.links=8.10 +#uni:difficulty (1=easy, 2=medium, 3=hard) +question.difficulty=2 +#Question Title (without prefix) +question.title=JUnit BankTest +question.type=essay diff --git a/samples/java/example14/solution/Numbers.java b/samples/java/example14/solution/Numbers.java new file mode 100644 index 00000000..6418752f --- /dev/null +++ b/samples/java/example14/solution/Numbers.java @@ -0,0 +1,4 @@ +public class Numbers +{ + public int square(int n) { return n * n; } +} diff --git a/samples/java/example14/student/NumbersTest.java b/samples/java/example14/student/NumbersTest.java new file mode 100644 index 00000000..27b309fa --- /dev/null +++ b/samples/java/example14/student/NumbersTest.java @@ -0,0 +1,24 @@ +//HIDDEN +import org.junit.Test; +import org.junit.Assert; + +public class NumbersTest +{ + @Test public void testNegative() + { + Numbers numbers = new Numbers(); + Assert.assertEquals(9, numbers.square(-3)); + } + + @Test public void testPositive() + { + Numbers numbers = new Numbers(); + Assert.assertEquals(9, numbers.square(3)); + } + + @Test public void testZero() + { + Numbers numbers = new Numbers(); + Assert.assertEquals(0, numbers.square(0)); + } +} diff --git a/samples/java/hiddenIN1/Numbers.java b/samples/java/hiddenIN1/Numbers.java new file mode 100644 index 00000000..82801124 --- /dev/null +++ b/samples/java/hiddenIN1/Numbers.java @@ -0,0 +1,27 @@ +//IN HIDDEN 3\n-3\n5\n0 +import java.util.Scanner; +public class Numbers +{ + public static void main(String[] args) + { + //HIDE + Scanner in = new Scanner(System.in); + boolean done = false; + while (!done) + { + //EDIT . . . + System.out.println("Enter a number, 0 to quit"); + //HIDE + int n = in.nextInt(); + if (n == 0) done = true; + else + { + int square = n * n; + //EDIT . . . + System.out.println("The square is " + square); + //HIDE + } + } + //EDIT . . . + } +} diff --git a/samples/java/hiddenIN2/Numbers.java b/samples/java/hiddenIN2/Numbers.java new file mode 100644 index 00000000..46336150 --- /dev/null +++ b/samples/java/hiddenIN2/Numbers.java @@ -0,0 +1,29 @@ +//SOLUTION +//IN HIDDEN 3\n-3\n0 +//IN 10\n100\n-1\n\u0030 +import java.util.Scanner; +public class Numbers +{ + public static void main(String[] args) + { + //HIDE + Scanner in = new Scanner(System.in); + boolean done = false; + while (!done) + { + //SHOW . . . + System.out.println("Enter a number, 0 to quit"); + //HIDE + int n = in.nextInt(); + if (n == 0) done = true; + else + { + int square = n * n; + //SHOW . . . + System.out.println("The square is " + square); + //HIDE + } + } + } + //SHOW +} diff --git a/samples/java/hiddenIN3/Numbers.java b/samples/java/hiddenIN3/Numbers.java new file mode 100644 index 00000000..72b2d5c2 --- /dev/null +++ b/samples/java/hiddenIN3/Numbers.java @@ -0,0 +1,27 @@ +//SOLUTION +import java.util.Scanner; +public class Numbers +{ + public static void main(String[] args) + { + //HIDE + Scanner in = new Scanner(System.in); + boolean done = false; + while (!done) + { + //SHOW . . . + System.out.println("Enter a number, 0 to quit"); + //HIDE + int n = in.nextInt(); + if (n == 0) done = true; + else + { + int square = n * n; + //SHOW . . . + System.out.println("The square is " + square); + //HIDE + } + } + //SHOW + } +} diff --git a/samples/java/hiddenIN3/test1.in b/samples/java/hiddenIN3/test1.in new file mode 100644 index 00000000..61d488f2 --- /dev/null +++ b/samples/java/hiddenIN3/test1.in @@ -0,0 +1,3 @@ +3 +-3 +0 diff --git a/samples/java/hiddenIN3/test2.in b/samples/java/hiddenIN3/test2.in new file mode 100644 index 00000000..2a87bc64 --- /dev/null +++ b/samples/java/hiddenIN3/test2.in @@ -0,0 +1,3 @@ +2 +4 +0 diff --git a/samples/kotlin/call1/ExpressionChecker.kt b/samples/kotlin/call1/ExpressionChecker.kt new file mode 100644 index 00000000..457a67ab --- /dev/null +++ b/samples/kotlin/call1/ExpressionChecker.kt @@ -0,0 +1,42 @@ +//CALL "(3+4)*5" +//CALL "(3+(4-5)*6)-7" +//CALL "(4+3+(4-5)*6)-7)" +//CALL "((3+(4-5)*6)-7" +//CALL "4+(3+(4-5)*6)-7" +fun checkParentheses(expression: String): Boolean +{ + val stk = ArrayDeque() + for (i in 0..(expression.length-1)) + { + val part = expression.substring(i, i + 1) + stk.addFirst(part) + //HIDE + if (part == ")") + { + var done = false + while (!done) + { + if (stk.isEmpty()) + return false + val top = stk.removeFirst() + done = (top == "(") + } + } + //EDIT + } + + while (!stk.isEmpty()) + { + val part = stk.removeFirst() + //HIDE + if (part == "(") + { + return false + } + //EDIT + } + + //HIDE + return true + //EDIT return . . . +} diff --git a/samples/kotlin/call1/index.html b/samples/kotlin/call1/index.html new file mode 100644 index 00000000..ce072db1 --- /dev/null +++ b/samples/kotlin/call1/index.html @@ -0,0 +1,16 @@ +

A stack can be used to check whether an arithmetic expression such as +

+ +
3*(4+(5-6))/7
+ +

has properly balanced parentheses. Break the expression into its + individual parts.

+ +
3 * ( 4 + ( 5 - 6 ) ) / 7
+ +

Push each part on a stack as it is encountered. When a ) is + encountered, pop everything until the matching (. If the stack is + prematurely empty, then report an error.

+ +

At the end, there should be no parentheses left. Otherwise, report an + error.

diff --git a/samples/kotlin/call2/ListOps.kt b/samples/kotlin/call2/ListOps.kt new file mode 100644 index 00000000..a816cf07 --- /dev/null +++ b/samples/kotlin/call2/ListOps.kt @@ -0,0 +1,27 @@ +/** + This method accepts and integer array as a parameter, and then + returns the "middle" value of the list. + For an array of odd length, this would be the actual middle value. + For an array of even length, there are TWO middle values, so only + the first of the two values is returned. + @param values, a list of integers + @return, the "middle" element of the list + */ +//CALL listOf(85, 53, 973, 789, 56, 113, 712) +//CALL listOf(8, 7, 6, 5, 4, 3, 2, 1) +//CALL listOf(-2) +//CALL listOf(2, -5, 11, -21, -8, 7, -6) +//CALL listOf(43, 21) +fun middleOfList(values: List): Int +{ + //HIDE + if (values.size % 2 != 0) + { + return values[values.size/2] + } + else + { + return values[(values.size/2) - 1] + } + //EDIT +} diff --git a/samples/kotlin/codecheck-bjlo-1e-01_02/Triangle.kt b/samples/kotlin/codecheck-bjlo-1e-01_02/Triangle.kt new file mode 100644 index 00000000..d3de74fd --- /dev/null +++ b/samples/kotlin/codecheck-bjlo-1e-01_02/Triangle.kt @@ -0,0 +1,12 @@ +/** + A program to draw a filled triangle. +*/ +fun main() +{ + println(" x") + //HIDE + println(" xxx") + println(" xxxxx") + println("xxxxxxx") + //EDIT +} diff --git a/samples/kotlin/codecheck-bjlo-1e-01_02/index.html b/samples/kotlin/codecheck-bjlo-1e-01_02/index.html new file mode 100644 index 00000000..aacb0961 --- /dev/null +++ b/samples/kotlin/codecheck-bjlo-1e-01_02/index.html @@ -0,0 +1,13 @@ +
+

+Complete the program below + to print out a filled triangle four lines high. + In other words, the output of this program should + match the following, exactly:

+
+     x
+    xxx
+   xxxxx
+  xxxxxxx
+
+
diff --git a/samples/kotlin/ebook-bjeo-7-ch01-sec06-cc-1/FixMe.kt b/samples/kotlin/ebook-bjeo-7-ch01-sec06-cc-1/FixMe.kt new file mode 100644 index 00000000..937cfa4b --- /dev/null +++ b/samples/kotlin/ebook-bjeo-7-ch01-sec06-cc-1/FixMe.kt @@ -0,0 +1,10 @@ +fun main() +{ + //HIDE + println("**********") + println("* *") + println("* Kotlin *") + println("* *") + println("**********") + //EDIT +} diff --git a/samples/kotlin/ebook-bjeo-7-ch01-sec06-cc-1/index.html b/samples/kotlin/ebook-bjeo-7-ch01-sec06-cc-1/index.html new file mode 100644 index 00000000..ab8169fb --- /dev/null +++ b/samples/kotlin/ebook-bjeo-7-ch01-sec06-cc-1/index.html @@ -0,0 +1,9 @@ +
+

Fix this program so that it prints out “Kotlin” in a box formed with asterisks, like this:

+
**********
+*        *
+* Kotlin *
+*        *
+**********
+
+
diff --git a/samples/kotlin/ebook-bjeo-7-ch04-sec02_04-cc-1/Sphere.kt b/samples/kotlin/ebook-bjeo-7-ch04-sec02_04-cc-1/Sphere.kt new file mode 100644 index 00000000..e90ac9d9 --- /dev/null +++ b/samples/kotlin/ebook-bjeo-7-ch04-sec02_04-cc-1/Sphere.kt @@ -0,0 +1,14 @@ +import kotlin.math.PI + +//IN 1 +//IN 5 +//IN 7.5 +fun main() +{ + print("Radius: ") + val r = readln().toDouble() + //HIDE + val volume = PI * 4 * r * r * r / 3 + //EDIT val volume = . . . + println("Volume: $volume") +} diff --git a/samples/kotlin/ebook-bjeo-7-ch04-sec02_04-cc-1/index.html b/samples/kotlin/ebook-bjeo-7-ch04-sec02_04-cc-1/index.html new file mode 100644 index 00000000..3932d1a2 --- /dev/null +++ b/samples/kotlin/ebook-bjeo-7-ch04-sec02_04-cc-1/index.html @@ -0,0 +1,6 @@ +
+

The volume of a sphere of radius r is + V=43πr3 +

+

Complete this program so that it computes the volume of a sphere with the given radius.

+
diff --git a/samples/kotlin/run1/Prog.kt b/samples/kotlin/run1/Prog.kt new file mode 100644 index 00000000..fd31c1bd --- /dev/null +++ b/samples/kotlin/run1/Prog.kt @@ -0,0 +1,22 @@ +import java.util.Scanner; + +fun main(args: Array) { + //HIDE + var done = false + while (!done) + { + //SHOW . . . + System.out.println("Enter a number, 0 to quit") + //HIDE + val n = readln().toInt() + if (n == 0) done = true + else + { + val square = n * n + //SHOW . . . + System.out.println("The square is " + square) + //HIDE + } + } + //SHOW +} diff --git a/samples/kotlin/run1/test.in b/samples/kotlin/run1/test.in new file mode 100644 index 00000000..61d488f2 --- /dev/null +++ b/samples/kotlin/run1/test.in @@ -0,0 +1,3 @@ +3 +-3 +0 diff --git a/samples/php/call1/function.php b/samples/php/call1/function.php new file mode 100644 index 00000000..54bf940b --- /dev/null +++ b/samples/php/call1/function.php @@ -0,0 +1,11 @@ + diff --git a/samples/php/call2/rainfall.php b/samples/php/call2/rainfall.php new file mode 100644 index 00000000..779c156c --- /dev/null +++ b/samples/php/call2/rainfall.php @@ -0,0 +1,26 @@ += 0 ) { + $sum += $rainfall[$i]; + $count++; + } + $i++; + } + if ( $count == 0 ) { + return 0; + } else { + return $sum / $count; + } + //EDIT ... +} + +?> diff --git a/samples/php/run1/numbers_runner.php b/samples/php/run1/numbers_runner.php new file mode 100644 index 00000000..54a8627d --- /dev/null +++ b/samples/php/run1/numbers_runner.php @@ -0,0 +1,17 @@ + diff --git a/samples/php/sub1/numbers_runner.php b/samples/php/sub1/numbers_runner.php new file mode 100644 index 00000000..430ced03 --- /dev/null +++ b/samples/php/sub1/numbers_runner.php @@ -0,0 +1,8 @@ + diff --git a/samples/php/tester1/function.php b/samples/php/tester1/function.php new file mode 100644 index 00000000..bd95a13c --- /dev/null +++ b/samples/php/tester1/function.php @@ -0,0 +1,9 @@ + diff --git a/samples/php/tester1/numbers_tester.php b/samples/php/tester1/numbers_tester.php new file mode 100644 index 00000000..47c8f5b0 --- /dev/null +++ b/samples/php/tester1/numbers_tester.php @@ -0,0 +1,12 @@ + diff --git a/samples/php/unittest/.phpunit.result.cache b/samples/php/unittest/.phpunit.result.cache new file mode 100644 index 00000000..0f4f6b20 --- /dev/null +++ b/samples/php/unittest/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":1,"defects":{"averageTest::test_average":3,"averageTest::test_average2":3},"times":{"averageTest::test_average":0.003,"averageTest::test_average2":0}} \ No newline at end of file diff --git a/samples/php/unittest/average.php b/samples/php/unittest/average.php new file mode 100644 index 00000000..1d1bc00f --- /dev/null +++ b/samples/php/unittest/average.php @@ -0,0 +1,9 @@ + diff --git a/samples/php/unittest/averageTest.php b/samples/php/unittest/averageTest.php new file mode 100644 index 00000000..554b9413 --- /dev/null +++ b/samples/php/unittest/averageTest.php @@ -0,0 +1,17 @@ +assertEquals(3.5, average(3, 4)); + $this->assertEquals(3, average(3, 3)); + $this->assertEquals(-0.5, average(0, -1)); + // To test failure + // $this->assertEquals(-0.5, average(0, -2)); + } +} + +?> diff --git a/samples/rust/example1/numbers.rs b/samples/rust/example1/numbers.rs new file mode 100644 index 00000000..165d49e5 --- /dev/null +++ b/samples/rust/example1/numbers.rs @@ -0,0 +1,20 @@ +//SOLUTION +use std::io; +fn main() { + let mut done = false; + while !done { + println!("Enter a number, 0 to quit"); + + let mut n = String::new(); + io::stdin().read_line(&mut n).expect("failed to readline"); + let mut trimmed = n.trim(); + let mut num = trimmed.parse::().unwrap(); + + if num == 0 { + done = true; + } else { + let mut square = num * num; + println!("The square is {}", square); + } + } +} \ No newline at end of file diff --git a/samples/rust/example1/test.in b/samples/rust/example1/test.in new file mode 100644 index 00000000..81a3b2d1 --- /dev/null +++ b/samples/rust/example1/test.in @@ -0,0 +1,3 @@ +3 +-3 +0 \ No newline at end of file diff --git a/samples/rust/example10/solution/linenums.rs b/samples/rust/example10/solution/linenums.rs new file mode 100644 index 00000000..346590f8 --- /dev/null +++ b/samples/rust/example10/solution/linenums.rs @@ -0,0 +1,27 @@ +//OUT output.txt +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::io::{BufWriter, Write}; + +fn main() -> Result<(), std::io::Error> { + let infile = File::open("input.txt")?; + let outfile = File::create("output.txt")?; + let mut reader = BufReader::new(infile); + let mut writer = BufWriter::new(outfile); + + let mut line = String::new(); + let mut num = 0; + loop { + num += 1; + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + break; + } + line = line.trim().to_string(); + line = format!(" {}: {}\n", num, line); + writer.write_all(line.as_bytes()); + line.clear(); + } + + Ok(()) +} \ No newline at end of file diff --git a/samples/rust/example10/student/input.txt b/samples/rust/example10/student/input.txt new file mode 100644 index 00000000..69650a46 --- /dev/null +++ b/samples/rust/example10/student/input.txt @@ -0,0 +1,9 @@ +Mary had a little lamb, +little lamb, little lamb, +Mary had a little lamb, +whose fleece was white as snow. + +And everywhere that Mary went, +Mary went, Mary went, +and everywhere that Mary went, +the lamb was sure to go. diff --git a/samples/rust/example2/numbers.rs b/samples/rust/example2/numbers.rs new file mode 100644 index 00000000..a873262c --- /dev/null +++ b/samples/rust/example2/numbers.rs @@ -0,0 +1,4 @@ +//SOLUTION +pub fn square(n: i32) -> i32 { + return n * n; +} \ No newline at end of file diff --git a/samples/rust/example2/numbers_tester.rs b/samples/rust/example2/numbers_tester.rs new file mode 100644 index 00000000..a609d230 --- /dev/null +++ b/samples/rust/example2/numbers_tester.rs @@ -0,0 +1,10 @@ +mod numbers; + +fn main() { + println!("{:?}", numbers::square(3)); + println!("Expected: 9"); + println!("{:?}", numbers::square(-3)); + println!("Expected: 9"); + println!("{:?}", numbers::square(0)); + println!("Expected: 0"); +} \ No newline at end of file diff --git a/samples/rust/example3/numbers.rs b/samples/rust/example3/numbers.rs new file mode 100644 index 00000000..2b46a20f --- /dev/null +++ b/samples/rust/example3/numbers.rs @@ -0,0 +1,7 @@ +//SOLUTION +//CALL 3.0, 4.0 +//CALL -3.0, 3.0 +//CALL 3.0, 0.0 +pub fn average(x: f32, y: f32) -> f32 { + return (x + y) / 2.0; +} \ No newline at end of file diff --git a/samples/rust/example4/numbers.rs b/samples/rust/example4/numbers.rs new file mode 100644 index 00000000..e42aea09 --- /dev/null +++ b/samples/rust/example4/numbers.rs @@ -0,0 +1,10 @@ +//SOLUTION +fn main() { + let x: f32 = 3.0; //SUB 5.0; 8.0 + let y: f32 = 4.0; //SUB 6.0; 4.0 + + let average: f32 = 0.5 * (x + y); + + println!("{:?}", average); + +} \ No newline at end of file diff --git a/samples/rust/example5/numbers.rs b/samples/rust/example5/numbers.rs new file mode 100644 index 00000000..f13903b0 --- /dev/null +++ b/samples/rust/example5/numbers.rs @@ -0,0 +1,22 @@ +//SOLUTION +//IN 3\n-3\n0 +//IN 10\n100\n-1\n\u0030 +use std::io; +fn main() { + let mut done = false; + while !done { + println!("Enter a number, 0 to quit"); + + let mut n = String::new(); + io::stdin().read_line(&mut n).expect("failed to readline"); + let mut trimmed = n.trim(); + let mut num = trimmed.parse::().unwrap(); + + if num == 0 { + done = true; + } else { + let mut square = num * num; + println!("The square is {}", square); + } + } +} \ No newline at end of file diff --git a/samples/rust/example6/numbers.rs b/samples/rust/example6/numbers.rs new file mode 100644 index 00000000..4b8a1c76 --- /dev/null +++ b/samples/rust/example6/numbers.rs @@ -0,0 +1,7 @@ +//SOLUTION +//CALL 3.0, 4.0 +//CALL HIDDEN -3.0, 3.0 +//CALL 3.0, 0.0 +pub fn average(x: f32, y: f32) -> f32 { + return (x + y) / 2.0; +} \ No newline at end of file diff --git a/samples/rust/example7/numbers.rs b/samples/rust/example7/numbers.rs new file mode 100644 index 00000000..a873262c --- /dev/null +++ b/samples/rust/example7/numbers.rs @@ -0,0 +1,4 @@ +//SOLUTION +pub fn square(n: i32) -> i32 { + return n * n; +} \ No newline at end of file diff --git a/samples/rust/example7/numbers_test.rs b/samples/rust/example7/numbers_test.rs new file mode 100644 index 00000000..293d653f --- /dev/null +++ b/samples/rust/example7/numbers_test.rs @@ -0,0 +1,20 @@ +mod numbers; + +#[cfg(test)] +mod test { + use super::numbers::square; + + #[test] + fn test_non_negative_squares() { + for i in 0..100 { + assert_eq!(square(i), i * i); + } + } + + #[test] + fn test_negative_squares() { + for i in -100..0 { + assert_eq!(square(i), i * i); + } + } +} \ No newline at end of file diff --git a/samples/rust/example8/double_array.rs b/samples/rust/example8/double_array.rs new file mode 100644 index 00000000..9c63fe7f --- /dev/null +++ b/samples/rust/example8/double_array.rs @@ -0,0 +1,10 @@ +//SOLUTION +fn main() { + let mut arr: [i32; 5]= [1,2,3,4,5]; //SUB [2,4,6,8,10]; [5,10,15,20,25]; [10,20,30,40,50] + + for n in 0..arr.len() { + arr[n] = arr[n] * 2; + } + + println!("{:?}", arr); +} \ No newline at end of file diff --git a/samples/rust/example9/create_array.rs b/samples/rust/example9/create_array.rs new file mode 100644 index 00000000..8ab3ab5f --- /dev/null +++ b/samples/rust/example9/create_array.rs @@ -0,0 +1,12 @@ +//SOLUTION +//CALL 5 +//CALL 10 +//CALL 1 +pub fn create_array(x: i32) -> Vec { + let mut arr: [i32; 5] = [0,0,0,0,0]; + for n in 0..arr.len() { + arr[n] = n as i32 + x; + } + + return arr.to_vec(); +} \ No newline at end of file diff --git a/samples/scala/hw1e/solution/Problem.scala b/samples/scala/hw1e/solution/Problem.scala new file mode 100644 index 00000000..9426fb05 --- /dev/null +++ b/samples/scala/hw1e/solution/Problem.scala @@ -0,0 +1,20 @@ +//FORBIDDEN (^|\PL)for\s*\( +//Do not use "for" +//FORBIDDEN (^|\PL)while(\PL|$) +//Do not use "while" +//FORBIDDEN (^|\PL)var(\PL|$) +//Do not use "var" +//FORBIDDEN (^|\PL)def(\PL|$) +//Do not use "def" +object Problem { + /** + * Checks whether a sequence of integer values in increasing + * @aparam a the sequence (of length >= 1) + * @return true if a(i) < a(i+1) for all adjacent elements + */ +//CALL 1.to(100) +//CALL Vector(1, 4, 9, 16, 9, 36) +//CALL Vector(1, 4, 9, 16, 16, 36) +//CALL Vector(1) + val isIncreasing = (a: Seq[Int]) => a.dropRight(1).zip(a.drop(1)).forall(p => p._1 < p._2) +} diff --git a/samples/scala/hw1e/student/Problem.scala b/samples/scala/hw1e/student/Problem.scala new file mode 100644 index 00000000..9f60cad9 --- /dev/null +++ b/samples/scala/hw1e/student/Problem.scala @@ -0,0 +1,8 @@ +object Problem { + /** + * Checks whether a sequence of integer values is increasing + * @aparam a the sequence (of length >= 1) + * @return true if a(i) < a(i+1) for all adjacent elements + */ + val isIncreasing = (a: Seq[Int]) => ... +}