001package edu.pdx.cs.joy.grader.canvas; 002 003import com.google.common.annotations.VisibleForTesting; 004import jakarta.json.Json; 005import jakarta.json.JsonArray; 006import jakarta.json.JsonNumber; 007import jakarta.json.JsonObject; 008import jakarta.json.JsonReader; 009import jakarta.json.JsonString; 010import jakarta.json.JsonValue; 011 012import java.io.IOException; 013import java.io.PrintStream; 014import java.io.StringReader; 015import java.net.URI; 016import java.net.http.HttpClient; 017import java.net.http.HttpHeaders; 018import java.net.http.HttpRequest; 019import java.net.http.HttpResponse; 020import java.nio.file.Files; 021import java.nio.file.Path; 022import java.time.Clock; 023import java.time.Instant; 024import java.time.LocalDate; 025import java.time.OffsetDateTime; 026import java.time.ZoneId; 027import java.time.format.DateTimeFormatterBuilder; 028import java.time.temporal.ChronoField; 029import java.util.ArrayList; 030import java.util.LinkedHashMap; 031import java.util.List; 032import java.util.Locale; 033import java.util.Map; 034import java.util.Optional; 035import java.util.TreeSet; 036import java.util.regex.Matcher; 037import java.util.regex.Pattern; 038import java.time.format.DateTimeFormatter; 039 040public class CompareCanvasAndWebsiteSchedules { 041 static final URI DEFAULT_CANVAS_BASE_URI = URI.create("https://canvas.pdx.edu"); 042 private static final Pattern NEXT_LINK_PATTERN = Pattern.compile("<([^>]+)>;\\s*rel=\"next\""); 043 private static final DateTimeFormatter WEBSITE_DATE_FORMAT = 044 DateTimeFormatter.ofPattern("MMMM d, uuuu", Locale.US); 045 private static final DateTimeFormatter COMMENT_DATE_WITH_OPTIONAL_YEAR_FORMAT = 046 new DateTimeFormatterBuilder() 047 .parseCaseInsensitive() 048 .appendPattern("MMMM d") 049 .optionalStart() 050 .appendLiteral(", ") 051 .appendValue(ChronoField.YEAR, 4) 052 .optionalEnd() 053 .toFormatter(Locale.US); 054 private static final ZoneId PACIFIC_TIME_ZONE = ZoneId.of("America/Los_Angeles"); 055 private static final String FINAL_EXAM_DURING_CLASS_TIME = "final exam during class time"; 056 private static final Pattern PROJECT_NAME_PATTERN = Pattern.compile("^Project \\d+$"); 057 private static final Pattern QUIZ_PREFIX_PATTERN = Pattern.compile("^quiz\\s+(\\d+)\\b.*"); 058 private static final Pattern REFLECTION_PATTERN = Pattern.compile("^reflections on\\s+(.+)$"); 059 private static final Pattern FINAL_EXAM_DATE_PATTERN = Pattern.compile( 060 "(?i)individual[^.]*final exam[^.]*?([A-Z][a-z]+\\s+\\d{1,2}(?:,\\s*\\d{4})?)|" + 061 "final exam[^.]*individual[^.]*?([A-Z][a-z]+\\s+\\d{1,2}(?:,\\s*\\d{4})?)"); 062 063 private final HttpClient httpClient; 064 private final URI canvasBaseUri; 065 private final Clock clock; 066 067 public CompareCanvasAndWebsiteSchedules() { 068 this(HttpClient.newHttpClient(), DEFAULT_CANVAS_BASE_URI, Clock.systemUTC()); 069 } 070 071 @VisibleForTesting 072 CompareCanvasAndWebsiteSchedules(HttpClient httpClient, URI canvasBaseUri) { 073 this(httpClient, canvasBaseUri, Clock.systemUTC()); 074 } 075 076 @VisibleForTesting 077 CompareCanvasAndWebsiteSchedules(HttpClient httpClient, URI canvasBaseUri, Clock clock) { 078 this.httpClient = httpClient; 079 this.canvasBaseUri = canvasBaseUri; 080 this.clock = clock; 081 } 082 083 public static void main(String[] args) throws IOException, InterruptedException { 084 try { 085 new CompareCanvasAndWebsiteSchedules().run(args, System.out); 086 087 } catch (IllegalArgumentException ex) { 088 usage(ex.getMessage()); 089 090 } catch (IllegalStateException ex) { 091 error(ex.getMessage()); 092 } 093 } 094 095 @VisibleForTesting 096 void run(String[] args, PrintStream out) throws IOException, InterruptedException { 097 String apiTokenFileName = parseApiTokenFileName(args); 098 String websiteJsonFileName = parseWebsiteJsonFileName(args); 099 String apiToken = readApiToken(Path.of(apiTokenFileName)); 100 CanvasCourse course = getNextCourse(apiToken); 101 List<CanvasAssignment> canvasAssignments = getAssignments(apiToken, course); 102 103 String websiteJson = readWebsiteJson(Path.of(websiteJsonFileName)); 104 List<WebsiteAssignment> websiteAssignments = parseWebsiteAssignments(websiteJson); 105 106 ComparisonReport report = compareAssignments(canvasAssignments, websiteAssignments); 107 printSection(out, "Assignments with differing due dates:", report.differingDueDates()); 108 out.println(); 109 printSection(out, "Assignments whose due dates couldn't be determined:", report.undeterminedDueDates()); 110 out.println(); 111 printSection(out, "Assignments with matching due dates:", report.matchingDueDates()); 112 } 113 114 private static String parseApiTokenFileName(String[] args) { 115 if (args.length == 0) { 116 throw new IllegalArgumentException("Missing API token file name"); 117 } 118 119 return args[0]; 120 } 121 122 private static String parseWebsiteJsonFileName(String[] args) { 123 if (args.length < 2) { 124 throw new IllegalArgumentException("Missing website JSON file name"); 125 } 126 127 if (args.length > 2) { 128 throw new IllegalArgumentException("Extraneous command line argument: " + args[2]); 129 } 130 131 return args[1]; 132 } 133 134 private static String readWebsiteJson(Path websiteJsonFile) throws IOException { 135 if (!Files.exists(websiteJsonFile)) { 136 throw new IllegalArgumentException("Website JSON file \"" + websiteJsonFile + "\" does not exist"); 137 } 138 139 return Files.readString(websiteJsonFile); 140 } 141 142 @VisibleForTesting 143 static List<WebsiteAssignment> parseWebsiteAssignments(String json) { 144 List<WebsiteAssignment> assignments = new ArrayList<>(); 145 try (JsonReader reader = Json.createReader(new StringReader(json))) { 146 JsonObject schedule = reader.readObject(); 147 JsonArray meetings = schedule.getJsonArray("meetings"); 148 JsonArray lectures = schedule.getJsonArray("lectures"); 149 150 if (meetings == null) { 151 throw new IllegalArgumentException("Website schedule is missing meetings"); 152 } 153 154 if (lectures == null) { 155 throw new IllegalArgumentException("Website schedule is missing lectures"); 156 } 157 158 if (lectures.size() > meetings.size()) { 159 throw new IllegalArgumentException("Website schedule has more lectures than meetings"); 160 } 161 162 for (int i = 0; i < lectures.size(); i++) { 163 JsonValue lectureValue = lectures.get(i); 164 if (lectureValue.getValueType() != JsonValue.ValueType.OBJECT) { 165 continue; 166 } 167 168 JsonObject lecture = lectureValue.asJsonObject(); 169 JsonObject topics = lecture.getJsonObject("topics"); 170 if (topics == null) { 171 continue; 172 } 173 174 LocalDate dueDate = parseWebsiteDate(meetings.getString(i)); 175 addDueAssignments(assignments, topics, dueDate); 176 } 177 178 addFinalExamAssignments(assignments, lectures, meetings, parseWebsiteDate(meetings.getString(meetings.size() - 1)).getYear()); 179 } 180 181 addPlanOfAttackAssignments(assignments); 182 return assignments; 183 } 184 185 private static LocalDate parseWebsiteDate(String text) { 186 return LocalDate.parse(text, WEBSITE_DATE_FORMAT); 187 } 188 189 private static void addDueAssignments(List<WebsiteAssignment> assignments, JsonObject topics, LocalDate dueDate) { 190 JsonValue due = topics.get("due"); 191 if (due instanceof JsonString) { 192 assignments.add(new WebsiteAssignment(((JsonString) due).getString(), dueDate)); 193 194 } else if (due != null && due.getValueType() == JsonValue.ValueType.ARRAY) { 195 JsonArray array = topics.getJsonArray("due"); 196 for (JsonValue value : array) { 197 if (value instanceof JsonString) { 198 assignments.add(new WebsiteAssignment(((JsonString) value).getString(), dueDate)); 199 } 200 } 201 } 202 203 JsonObject quiz = topics.getJsonObject("quiz"); 204 if (quiz != null && quiz.get("number") instanceof JsonNumber) { 205 assignments.add(new WebsiteAssignment("Quiz " + quiz.getInt("number"), dueDate)); 206 } 207 208 JsonObject survey = topics.getJsonObject("survey"); 209 if (survey != null && survey.get("name") instanceof JsonString) { 210 assignments.add(new WebsiteAssignment(survey.getString("name") + " Survey", dueDate)); 211 } 212 213 JsonObject reflection = topics.getJsonObject("reflection"); 214 if (reflection != null && reflection.get("title") instanceof JsonString) { 215 assignments.add(new WebsiteAssignment("Reflections on " + reflection.getString("title"), dueDate)); 216 } 217 } 218 219 private static void addPlanOfAttackAssignments(List<WebsiteAssignment> assignments) { 220 List<WebsiteAssignment> poaAssignments = new ArrayList<>(); 221 for (WebsiteAssignment assignment : assignments) { 222 if (PROJECT_NAME_PATTERN.matcher(assignment.name()).matches()) { 223 poaAssignments.add(new WebsiteAssignment(assignment.name() + " POA", assignment.dueDate().minusDays(3))); 224 } 225 } 226 227 assignments.addAll(poaAssignments); 228 } 229 230 private static void addFinalExamAssignments(List<WebsiteAssignment> assignments, JsonArray lectures, JsonArray meetings, int defaultYear) { 231 LocalDate individualDueDate = findFinalExamIndividualDueDate(lectures, meetings, defaultYear); 232 if (individualDueDate == null) { 233 return; 234 } 235 236 assignments.add(new WebsiteAssignment("Final Exam (Individual)", individualDueDate)); 237 assignments.add(new WebsiteAssignment("Final Exam (Group)", individualDueDate.plusDays(1))); 238 } 239 240 private static LocalDate findFinalExamIndividualDueDate(JsonArray lectures, JsonArray meetings, int defaultYear) { 241 for (int i = 0; i < lectures.size(); i++) { 242 JsonValue lectureValue = lectures.get(i); 243 if (lectureValue.getValueType() != JsonValue.ValueType.OBJECT) { 244 continue; 245 } 246 247 JsonObject lecture = lectureValue.asJsonObject(); 248 JsonValue comment = lecture.get("comment"); 249 if (comment instanceof JsonString) { 250 String commentText = ((JsonString) comment).getString(); 251 LocalDate maybeDate = parseFinalExamDateFromComment(commentText, defaultYear); 252 if (maybeDate == null && commentText.toLowerCase(Locale.US).contains(FINAL_EXAM_DURING_CLASS_TIME)) { 253 maybeDate = parseWebsiteDate(meetings.getString(i)); 254 } 255 if (maybeDate != null) { 256 return maybeDate; 257 } 258 } 259 } 260 261 return null; 262 } 263 264 private static LocalDate parseFinalExamDateFromComment(String comment, int defaultYear) { 265 Matcher matcher = FINAL_EXAM_DATE_PATTERN.matcher(comment); 266 if (!matcher.find()) { 267 return null; 268 } 269 270 String dateText = matcher.group(1) != null ? matcher.group(1) : matcher.group(2); 271 if (dateText == null) { 272 return null; 273 } 274 275 LocalDate parsed = LocalDate.parse(dateText.trim(), COMMENT_DATE_WITH_OPTIONAL_YEAR_FORMAT); 276 if (!dateText.contains(",")) { 277 parsed = parsed.withYear(defaultYear); 278 } 279 280 return parsed; 281 } 282 283 private static String readApiToken(Path apiTokenFile) throws IOException { 284 if (!Files.exists(apiTokenFile)) { 285 throw new IllegalArgumentException("API token file \"" + apiTokenFile + "\" does not exist"); 286 } 287 288 String apiToken = Files.readString(apiTokenFile).trim(); 289 if (apiToken.isEmpty()) { 290 throw new IllegalArgumentException("API token file \"" + apiTokenFile + "\" is empty"); 291 } 292 293 return apiToken; 294 } 295 296 @VisibleForTesting 297 CanvasCourse getNextCourse(String apiToken) throws IOException, InterruptedException { 298 List<CanvasCourse> courses = new ArrayList<>(); 299 URI nextPage = getCoursesUri(); 300 301 while (nextPage != null) { 302 HttpResponse<String> response = invokeCanvas(nextPage, apiToken); 303 courses.addAll(parseCourses(response.body())); 304 nextPage = getNextPage(response.headers()); 305 } 306 307 Instant now = this.clock.instant(); 308 Optional<CanvasCourse> nextCourse = courses.stream() 309 .filter(course -> course.startAt().isAfter(now)) 310 .min((left, right) -> left.startAt().compareTo(right.startAt())); 311 312 return nextCourse 313 .orElseThrow(() -> new IllegalStateException("No upcoming Canvas course offerings found")); 314 } 315 316 @VisibleForTesting 317 List<CanvasAssignment> getAssignments(String apiToken, CanvasCourse course) throws IOException, InterruptedException { 318 List<CanvasAssignment> assignments = new ArrayList<>(); 319 URI nextPage = getAssignmentsUri(course); 320 321 while (nextPage != null) { 322 HttpResponse<String> response = invokeCanvas(nextPage, apiToken); 323 assignments.addAll(parseAssignments(response.body())); 324 nextPage = getNextPage(response.headers()); 325 } 326 327 return assignments; 328 } 329 330 private URI getCoursesUri() { 331 return this.canvasBaseUri.resolve("/api/v1/courses?per_page=100"); 332 } 333 334 private URI getAssignmentsUri(CanvasCourse course) { 335 return this.canvasBaseUri.resolve("/api/v1/courses/" + course.id() + "/assignments?per_page=100"); 336 } 337 338 private HttpResponse<String> invokeCanvas(URI uri, String apiToken) throws IOException, InterruptedException { 339 HttpRequest request = HttpRequest.newBuilder(uri) 340 .header("Authorization", "Bearer " + apiToken) 341 .header("Accept", "application/json") 342 .GET() 343 .build(); 344 345 HttpResponse<String> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString()); 346 if (response.statusCode() != 200) { 347 throw new IOException("Canvas request to " + uri + " failed with status code " + response.statusCode()); 348 } 349 350 return response; 351 } 352 353 private URI getNextPage(HttpHeaders headers) { 354 for (String linkHeader : headers.allValues("Link")) { 355 Matcher matcher = NEXT_LINK_PATTERN.matcher(linkHeader); 356 if (matcher.find()) { 357 return URI.create(matcher.group(1)); 358 } 359 } 360 361 return null; 362 } 363 364 @VisibleForTesting 365 static List<CanvasCourse> parseCourses(String json) { 366 List<CanvasCourse> courses = new ArrayList<>(); 367 try (JsonReader reader = Json.createReader(new StringReader(json))) { 368 JsonArray jsonCourses = reader.readArray(); 369 for (JsonValue courseValue : jsonCourses) { 370 if (courseValue.getValueType() != JsonValue.ValueType.OBJECT) { 371 continue; 372 } 373 374 JsonObject course = courseValue.asJsonObject(); 375 JsonValue id = course.get("id"); 376 JsonValue name = course.get("name"); 377 JsonValue startAt = course.get("start_at"); 378 if (id instanceof JsonNumber && name instanceof JsonString && startAt instanceof JsonString) { 379 courses.add(new CanvasCourse( 380 ((JsonNumber) id).intValue(), 381 ((JsonString) name).getString(), 382 OffsetDateTime.parse(((JsonString) startAt).getString()).toInstant())); 383 } 384 } 385 386 return courses; 387 } 388 } 389 390 @VisibleForTesting 391 static List<CanvasAssignment> parseAssignments(String json) { 392 List<CanvasAssignment> assignments = new ArrayList<>(); 393 try (JsonReader reader = Json.createReader(new StringReader(json))) { 394 JsonArray jsonAssignments = reader.readArray(); 395 for (JsonValue assignmentValue : jsonAssignments) { 396 if (assignmentValue.getValueType() != JsonValue.ValueType.OBJECT) { 397 continue; 398 } 399 400 JsonObject assignment = assignmentValue.asJsonObject(); 401 JsonValue name = assignment.get("name"); 402 if (!(name instanceof JsonString)) { 403 continue; 404 } 405 406 JsonValue dueAt = assignment.get("due_at"); 407 Optional<LocalDate> dueDate = Optional.empty(); 408 if (dueAt instanceof JsonString) { 409 dueDate = Optional.of(OffsetDateTime.parse(((JsonString) dueAt).getString()) 410 .atZoneSameInstant(PACIFIC_TIME_ZONE) 411 .toLocalDate()); 412 } 413 414 assignments.add(new CanvasAssignment(((JsonString) name).getString(), dueDate)); 415 } 416 417 return assignments; 418 } 419 } 420 421 @VisibleForTesting 422 static ComparisonReport compareAssignments(List<CanvasAssignment> canvasAssignments, List<WebsiteAssignment> websiteAssignments) { 423 List<ComparisonRow> differing = new ArrayList<>(); 424 List<ComparisonRow> undetermined = new ArrayList<>(); 425 List<ComparisonRow> matching = new ArrayList<>(); 426 427 List<WebsiteAssignment> unmatchedWebsiteAssignments = new ArrayList<>(websiteAssignments); 428 for (CanvasAssignment canvasAssignment : canvasAssignments) { 429 WebsiteAssignment websiteAssignment = findAndRemoveMatchingWebsiteAssignment(canvasAssignment, unmatchedWebsiteAssignments); 430 431 if (websiteAssignment == null) { 432 undetermined.add(new ComparisonRow( 433 canvasAssignment.name(), 434 formatCanvasDate(canvasAssignment.dueDate()), 435 formatWebsiteDate(null))); 436 437 } else { 438 ComparisonRow row = new ComparisonRow( 439 websiteAssignment.name(), 440 formatCanvasDate(canvasAssignment.dueDate()), 441 formatWebsiteDate(websiteAssignment.dueDate())); 442 443 if (canvasAssignment.dueDate().isEmpty()) { 444 undetermined.add(row); 445 446 } else if (canvasAssignment.dueDate().get().equals(websiteAssignment.dueDate())) { 447 matching.add(row); 448 449 } else { 450 differing.add(row); 451 } 452 } 453 } 454 455 for (WebsiteAssignment websiteAssignment : unmatchedWebsiteAssignments) { 456 undetermined.add(new ComparisonRow( 457 websiteAssignment.name(), 458 formatCanvasDate(null), 459 formatWebsiteDate(websiteAssignment.dueDate()))); 460 } 461 462 sortRowsByWebsiteDueDate(differing); 463 sortRowsByWebsiteDueDate(undetermined); 464 sortRowsByWebsiteDueDate(matching); 465 466 return new ComparisonReport(differing, undetermined, matching); 467 } 468 469 private static WebsiteAssignment findAndRemoveMatchingWebsiteAssignment(CanvasAssignment canvasAssignment, 470 List<WebsiteAssignment> websiteAssignments) { 471 WebsiteAssignment match = findMatchingWebsiteAssignment(canvasAssignment, websiteAssignments, 472 (canvas, website) -> canvas.equals(website)); 473 if (match == null) { 474 match = findMatchingWebsiteAssignment(canvasAssignment, websiteAssignments, 475 (canvas, website) -> canvas.equalsIgnoreCase(website)); 476 } 477 if (match == null) { 478 String normalizedCanvasName = normalizeAssignmentName(canvasAssignment.name()); 479 match = findMatchingWebsiteAssignment(canvasAssignment, websiteAssignments, 480 (canvas, website) -> normalizedCanvasName.equals(normalizeAssignmentName(website))); 481 } 482 483 if (match != null) { 484 websiteAssignments.remove(match); 485 } 486 return match; 487 } 488 489 private static WebsiteAssignment findMatchingWebsiteAssignment(CanvasAssignment canvasAssignment, 490 List<WebsiteAssignment> websiteAssignments, 491 AssignmentNameMatcher matcher) { 492 for (WebsiteAssignment websiteAssignment : websiteAssignments) { 493 if (matcher.matches(canvasAssignment.name(), websiteAssignment.name())) { 494 return websiteAssignment; 495 } 496 } 497 498 return null; 499 } 500 501 private static String normalizeAssignmentName(String name) { 502 String normalized = name.toLowerCase(Locale.US).trim(); 503 normalized = normalized.replace(':', ' '); 504 normalized = normalized.replaceAll("\\s+", " "); 505 normalized = normalizeQuizName(normalized); 506 normalized = normalizeReflectionName(normalized); 507 return normalized.trim(); 508 } 509 510 private static String normalizeQuizName(String normalized) { 511 Matcher matcher = QUIZ_PREFIX_PATTERN.matcher(normalized); 512 if (matcher.matches()) { 513 return "quiz " + matcher.group(1); 514 } 515 516 return normalized; 517 } 518 519 private static String normalizeReflectionName(String normalized) { 520 Matcher matcher = REFLECTION_PATTERN.matcher(normalized); 521 if (!matcher.matches()) { 522 return normalized; 523 } 524 525 String topic = matcher.group(1) 526 .replace("your experiences ", "") 527 .trim(); 528 return "reflections on " + topic; 529 } 530 531 private static void sortRowsByWebsiteDueDate(List<ComparisonRow> rows) { 532 rows.sort((left, right) -> { 533 int byDate = websiteSortKey(left).compareTo(websiteSortKey(right)); 534 if (byDate != 0) { 535 return byDate; 536 } 537 538 return left.assignmentName().compareToIgnoreCase(right.assignmentName()); 539 }); 540 } 541 542 private static LocalDate websiteSortKey(ComparisonRow row) { 543 if ("(not found)".equals(row.websiteDueDate())) { 544 return LocalDate.MAX; 545 } 546 547 return LocalDate.parse(row.websiteDueDate()); 548 } 549 550 private static String formatCanvasDate(Optional<LocalDate> dueDate) { 551 if (dueDate == null) { 552 return "(not found)"; 553 } 554 555 return dueDate.map(LocalDate::toString).orElse("(no due date)"); 556 } 557 558 private static String formatWebsiteDate(LocalDate dueDate) { 559 if (dueDate == null) { 560 return "(not found)"; 561 } 562 563 return dueDate.toString(); 564 } 565 566 private static void printSection(PrintStream out, String heading, List<ComparisonRow> rows) { 567 out.println(heading); 568 if (rows.isEmpty()) { 569 out.println("(none)"); 570 571 } else { 572 int assignmentWidth = "Assignment".length(); 573 int canvasWidth = "Canvas".length(); 574 int websiteWidth = "Website".length(); 575 576 for (ComparisonRow row : rows) { 577 assignmentWidth = Math.max(assignmentWidth, row.assignmentName().length()); 578 canvasWidth = Math.max(canvasWidth, row.canvasDueDate().length()); 579 websiteWidth = Math.max(websiteWidth, row.websiteDueDate().length()); 580 } 581 582 out.println(formatTableRow("Assignment", "Canvas", "Website", assignmentWidth, canvasWidth, websiteWidth)); 583 out.println(formatTableRow("-".repeat(assignmentWidth), "-".repeat(canvasWidth), "-".repeat(websiteWidth), 584 assignmentWidth, canvasWidth, websiteWidth)); 585 for (ComparisonRow row : rows) { 586 out.println(formatTableRow(row.assignmentName(), row.canvasDueDate(), row.websiteDueDate(), 587 assignmentWidth, canvasWidth, websiteWidth)); 588 } 589 } 590 } 591 592 private static String formatTableRow(String assignment, String canvas, String website, int assignmentWidth, int canvasWidth, 593 int websiteWidth) { 594 return padRight(assignment, assignmentWidth) + " " + 595 padRight(canvas, canvasWidth) + " " + 596 website; 597 } 598 599 private static String padRight(String value, int width) { 600 return value + " ".repeat(width - value.length()); 601 } 602 603 private static void usage(String message) { 604 PrintStream err = System.err; 605 606 err.println("+++ " + message); 607 err.println(); 608 err.println("usage: java CompareCanvasAndWebsiteSchedules apiTokenFileName websiteJsonFileName"); 609 err.println(" apiTokenFileName File containing the Canvas API token"); 610 err.println(" websiteJsonFileName File containing the website schedule JSON"); 611 err.println(); 612 err.println("Compares assignment due dates from Canvas and the website schedule JSON"); 613 err.println(); 614 615 System.exit(1); 616 } 617 618 private static void error(String message) { 619 System.err.println("+++ " + message); 620 System.exit(1); 621 } 622 623 @VisibleForTesting 624 static record CanvasCourse(int id, String name, Instant startAt) { 625 } 626 627 @VisibleForTesting 628 static record CanvasAssignment(String name, Optional<LocalDate> dueDate) { 629 String dueDateAsText() { 630 return this.dueDate.map(LocalDate::toString).orElse("(no due date)"); 631 } 632 } 633 634 @VisibleForTesting 635 static record WebsiteAssignment(String name, LocalDate dueDate) { 636 String dueDateAsText() { 637 return this.dueDate.toString(); 638 } 639 } 640 641 @VisibleForTesting 642 static record ComparisonReport(List<ComparisonRow> differingDueDates, List<ComparisonRow> undeterminedDueDates, List<ComparisonRow> matchingDueDates) { 643 } 644 645 @VisibleForTesting 646 static record ComparisonRow(String assignmentName, String canvasDueDate, String websiteDueDate) { 647 } 648 649 @FunctionalInterface 650 private interface AssignmentNameMatcher { 651 boolean matches(String canvasName, String websiteName); 652 } 653}