001package edu.pdx.cs410J.grader;
002
003import com.google.common.annotations.VisibleForTesting;
004import edu.pdx.cs410J.ParserException;
005import edu.pdx.cs410J.grader.gradebook.Student;
006import edu.pdx.cs410J.grader.gradebook.XmlStudentParser;
007import jakarta.activation.DataHandler;
008import jakarta.activation.DataSource;
009import jakarta.activation.FileDataSource;
010import jakarta.mail.MessagingException;
011import jakarta.mail.Multipart;
012import jakarta.mail.Transport;
013import jakarta.mail.internet.*;
014
015import java.io.*;
016import java.time.LocalDateTime;
017import java.time.format.DateTimeFormatter;
018import java.time.format.DateTimeParseException;
019import java.time.format.FormatStyle;
020import java.util.*;
021import java.util.jar.Attributes;
022import java.util.regex.Matcher;
023import java.util.regex.Pattern;
024import java.util.stream.Collectors;
025import java.util.stream.Stream;
026
027/**
028 * This class is used to submit assignments in CS410J.  The user
029 * specified his or her email address as well as the base directory
030 * for his/her source files on the command line.  The directory is
031 * searched recursively for files that are allowed to be submitted.
032 * Those files are
033 * placed in a zip file and emailed to the grader.  A confirmation
034 * email is sent to the submitter.
035 *
036 * More information about the JavaMail API can be found at:
037 *
038 * <a href="http://java.sun.com/products/javamail">
039 * http://java.sun.com/products/javamail</a>
040 *
041 * @author David Whitlock
042 * @since Fall 2000 (Refactored to use fewer static methods in Spring 2006)
043 */
044public class Submit extends EmailSender {
045
046  private static final PrintWriter out = new PrintWriter(System.out, true);
047  private static final PrintWriter err = new PrintWriter(System.err, true);
048
049  /////////////////////  Instance Fields  //////////////////////////
050
051  /**
052   * The name of the project being submitted
053   */
054  private String projName = null;
055
056  /**
057   * The name of the user (student) submits the project
058   */
059  private String userName = null;
060
061  /**
062   * The submitter's email address
063   */
064  private String userEmail = null;
065
066  /**
067   * The submitter's user id
068   */
069  private String userId = null;
070
071  /**
072   * A comment describing the project
073   */
074  private String comment = null;
075
076  /**
077   * Should the execution of this program be logged?
078   */
079  private boolean debug = false;
080
081  /**
082   * Should the generated zip file be saved?
083   */
084  private boolean saveZip = false;
085
086  private boolean sendEmails = true;
087
088  /**
089   * The time at which the project was submitted
090   */
091  private LocalDateTime submitTime = null;
092
093  /**
094   * The names of the files to be submitted
095   */
096  private final Set<String> fileNames = new HashSet<>();
097
098  private boolean isSubmittingKoans = false;
099  private boolean sendReceipt = true;
100  private String studentXmlFileName;
101  private Double estimatedHours;
102  private final CurrentTimeProvider currentTimeProvider;
103  private BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
104
105  ///////////////////////  Constructors  /////////////////////////
106
107  /**
108   * Creates a new <code>Submit</code> program
109   */
110  Submit() {
111    this(LocalDateTime::now);
112  }
113
114  @VisibleForTesting
115  Submit(CurrentTimeProvider currentTimeProvider) {
116    this.currentTimeProvider = currentTimeProvider;
117  }
118
119  /////////////////////  Instance Methods  ///////////////////////
120
121  /**
122   * Sets the name of the SMTP server that is used to send emails
123   */
124  void setEmailServerHostName(String serverName) {
125    EmailSender.serverName = serverName;
126  }
127
128  /**
129   * Sets whether or not the progress of the submission should be logged.
130   */
131  public void setDebug(boolean debug) {
132    this.debug = debug;
133  }
134
135  /**
136   * Sets whether or not the zip file generated by the submission
137   * should be saved.
138   */
139  private void saveZip() {
140    this.saveZip = true;
141  }
142
143  private void doNotSendEmails() {
144    this.sendEmails = false;
145  }
146
147  /**
148   * Sets the comment for this submission
149   */
150  private void setComment(String comment) {
151    this.comment = comment;
152  }
153
154  /**
155   * Sets the name of project being submitted
156   */
157  void setProjectName(String projName) {
158    this.projName = projName;
159  }
160
161  /**
162   * Adds the file with the given name to the list of files to be
163   * submitted.
164   */
165  void addFile(String fileName) {
166    this.fileNames.add(fileName);
167  }
168
169  /**
170   * Validates the state of this submission
171   *
172   * @throws IllegalStateException If any state is incorrect or missing
173   */
174  private void validate() {
175    validateProjectName();
176
177    if (projName == null) {
178      throw new IllegalStateException("Missing project name");
179    }
180
181    validateStudentXmlFile();
182
183    if (userName == null) {
184      throw new IllegalStateException("Missing student name");
185    }
186
187    if (userId == null) {
188      throw new IllegalStateException("Missing login id");
189    }
190
191    if (isNineDigitStudentId(userId)) {
192      throw new IllegalStateException(loginIdShouldNotBeStudentId(userId));
193    }
194
195    if (looksLikeAnEmailAddress(userId)) {
196      throw new IllegalStateException(loginIdShouldNotBeEmailAddress(userId));
197    }
198
199    if (userEmail == null) {
200      throw new IllegalStateException("Missing email address");
201
202    } else {
203      // Make sure user's email is okay
204      try {
205        new InternetAddress(userEmail);
206
207      } catch (AddressException ex) {
208        String m = "Invalid email address: " + userEmail;
209        throw new IllegalStateException(m, ex);
210      }
211    }
212  }
213
214  private void validateStudentXmlFile() {
215    if (this.studentXmlFileName == null) {
216      throw new IllegalStateException("Missing Student XML File");
217    }
218
219    File studentXmlFile = new File(this.studentXmlFileName);
220    if (!studentXmlFile.exists()) {
221      throw new IllegalStateException("Student XML file \"" + studentXmlFile + "\" doesn't exist");
222    }
223
224    Student student;
225    try {
226      student = new XmlStudentParser(studentXmlFile).parseStudent();
227    } catch (ParserException | IOException ex) {
228      throw new IllegalStateException("Could not parse Student XML file \"" + studentXmlFile + "\"", ex);
229    }
230
231    setStudent(student);
232
233  }
234
235  @VisibleForTesting
236  void setStudent(Student student) {
237    this.userId = student.getId();
238    this.userName = student.getFirstName() + " " + student.getLastName();
239    this.userEmail = student.getEmail();
240  }
241
242  private String loginIdShouldNotBeEmailAddress(String userId) {
243    return "Your login id (" + userId + ") should not be an email address";
244  }
245
246  @VisibleForTesting
247  boolean looksLikeAnEmailAddress(String userId) {
248    return userId.contains("@");
249  }
250
251  @VisibleForTesting
252  boolean isNineDigitStudentId(String userId) {
253    return userId.matches("^[0-9]{9}$");
254  }
255
256  private String loginIdShouldNotBeStudentId(String userId) {
257    return "Your login id (" + userId + ") should not be your 9-digit student id";
258  }
259
260  @VisibleForTesting
261  void validateProjectName() {
262    List<String> validProjectNames = fetchListOfValidProjectNames();
263    if (!validProjectNames.contains(projName)) {
264      String message = "\"" + projName + "\" is not in the list of valid project names: " + validProjectNames;
265      throw new IllegalStateException(message);
266    }
267  }
268
269  private List<String> fetchListOfValidProjectNames() {
270    return fetchListOfStringsFromResource("project-names");
271  }
272
273  private List<String> fetchListOfStringsFromResource(String resourceName) {
274    InputStream stream = this.getClass().getResourceAsStream(resourceName);
275    if (stream == null) {
276      throw new IllegalStateException("Cannot load " + resourceName);
277    }
278
279    List<String> strings = new ArrayList<>();
280    try {
281
282      InputStreamReader isr = new InputStreamReader(stream);
283      BufferedReader br = new BufferedReader(isr);
284      while (br.ready()) {
285        strings.add(br.readLine().trim());
286      }
287
288    } catch (IOException ex) {
289      err.println("** WARNING: Problems while reading " + resourceName + ": " + ex.getMessage());
290    }
291
292    return strings;
293  }
294
295  /**
296   * Submits the project to the grader
297   *
298   * @param verify Should the user be prompted to verify his submission?
299   * @return Whether or not the submission actually occurred
300   * @throws IllegalStateException If no source files were found
301   */
302  public boolean submit(boolean verify) throws IOException, MessagingException {
303    // Recursively search the source directory for .java files
304    Set<File> sourceFiles = searchForSourceFiles(fileNames);
305
306    db(sourceFiles.size() + " source files found");
307
308    if (sourceFiles.size() == 0) {
309      String s = "No source files were found.";
310      throw new IllegalStateException(s);
311    } else if (sourceFiles.size() < 3) {
312      String s = "Too few source files were submitted. Each project requires at least 3 files be" + 
313                 " submitted. You only submitted " + sourceFiles.size() + " files";
314      throw new IllegalStateException(s);
315    }
316
317    // Verify submission with user
318    if (verify && !verifySubmission(sourceFiles)) {
319      // User does not want to submit
320      return false;
321    }
322
323    // Timestamp
324    this.submitTime = this.currentTimeProvider.getCurrentTime();
325
326    // Create a temporary zip file to hold the source files
327    File zipFile = makeZipFileWith(sourceFiles);
328
329    if (this.sendEmails) {
330      // Send the zip file as an email attachment to the TA
331      mailTA(zipFile, sourceFiles);
332
333      if (sendReceipt) {
334        mailReceipt(sourceFiles);
335      }
336    }
337
338    return true;
339  }
340
341  private void askForEstimatedHours() {
342    out.println("\nTo help inform how future students plan their approach to this project, you can");
343    out.println("record the approximate number of hours you spent on this project. This estimate");
344    out.println("is completely optional, is used for informational purposes only, has no impact");
345    out.println("on your grade, and will only be shared with others as part of an aggregate");
346    out.println("summary report.\n");
347
348    while (true) {
349      out.print("About how many hours did you spend on this project?  ");
350      out.flush();
351
352      try {
353        String line = in.readLine();
354        if (line == null || line.isEmpty()) {
355          return;
356        }
357
358        try {
359          double estimatedHours = Double.parseDouble(line.trim());
360          this.setEstimatedHours(estimatedHours);
361          return;
362
363        } catch (NumberFormatException ex) {
364          out.println("** Please enter a valid number or a blank line to opt out");
365        }
366
367      } catch (IOException ex) {
368        err.println("** Exception while reading from System.in: " + ex);
369      }
370    }
371
372  }
373
374  /**
375   * Prints debugging output.
376   */
377  private void db(String s) {
378    if (this.debug) {
379      err.println("++ " + s);
380    }
381  }
382
383  /**
384   * Searches for the files given on the command line.  Ignores files
385   * that do not end in .java, or that appear on the "no submit" list.
386   * Files must reside in a directory named
387   * edu/pdx/cs410J/<studentId>.
388   */
389  private Set<File> searchForSourceFiles(Set<String> fileNames) {
390    // Files should be sorted by name
391    SortedSet<File> files =
392      new TreeSet<>(Comparator.comparing(File::toString));
393    populateWithFilesFromSubdirectories(files, fileNames);
394
395    files.removeIf((file) -> !canBeSubmitted(file));
396
397    return files;
398  }
399
400  private void populateWithFilesFromSubdirectories(SortedSet<File> allFiles, Set<String> fileNames) {
401    populateWithFilesFromSubdirectories(allFiles, fileNames.stream().map(name -> new File(name).getAbsoluteFile()));
402  }
403
404  private void populateWithFilesFromSubdirectories(SortedSet<File> allFiles, Stream<File> files) {
405    files.forEach((file) -> {
406      if (file.isDirectory()) {
407        populateWithFilesFromSubdirectories(allFiles, Arrays.stream(Objects.requireNonNull(file.listFiles())));
408
409      } else {
410        allFiles.add(file);
411      }
412    });
413
414  }
415
416  protected boolean canBeSubmitted(File file) {
417    if (!fileExists(file)) {
418      return false;
419    }
420
421    // Is the file on the "no submit" list?
422    List<String> noSubmit = fetchListOfFilesThatCanNotBeSubmitted();
423    String name = file.getName();
424    if (noSubmit.contains(name)) {
425      err.println("** Not submitting file " + file +
426        " because it is on the \"no submit\" list");
427      return false;
428    }
429
430    // Verify that file is in the correct directory.
431    if (isInAKoansDirectory(file)) {
432      if (!file.getName().endsWith(".java")) {
433        err.println("** Not submitting file " + file +
434          " because does end in \".java\"");
435        return false;
436      }
437
438    } else {
439      if (!isInMavenProjectDirectory(file)) {
440        err.println("** Not submitting file " + file +
441          ": it does not reside in a Maven project in a directory named " +
442          "edu" + File.separator + "pdx" + File.separator +
443          "cs410J" + File.separator + userId + " (or in one of the koans directories)");
444        return false;
445      }
446
447      // Does the file name end in .java?
448      if (!canFileBeSubmitted(file)) {
449        String fileName = file.getName();
450        int index = fileName.lastIndexOf('.');
451        if (index < 0) {
452          err.println("** Not submitting file " + file +
453            " because it does not have an extension.");
454
455        } else {
456          String fileExtension = fileName.substring(index);
457          err.println("** Not submitting file " + file +
458            " because files ending in " + fileExtension +
459            " are not supposed to be in that directory");
460        }
461        return false;
462      }
463    }
464    return true;
465  }
466
467  @VisibleForTesting
468  static boolean canFileBeSubmitted(File file) {
469    String path = file.getPath();
470
471    if (path.matches(".*src/.*/java/.*")) {
472      return path.endsWith(".java");
473
474    } else if (path.matches(".*src/.*/javadoc/.*")) {
475      return path.endsWith(".html");
476
477    } else if (path.matches(".*src/.*/resources/.*")) {
478      return path.endsWith(".xml") || path.endsWith(".txt");
479
480    } else {
481      return false;
482    }
483  }
484
485  boolean fileExists(File file) {
486    if (!file.exists()) {
487      err.println("** Not submitting file " + file +
488        " because it does not exist");
489      return false;
490    }
491    return true;
492  }
493
494  private boolean isInAKoansDirectory(File file) {
495    boolean isInAKoansDirectory = hasParentDirectories(file, "beginner") ||
496      hasParentDirectories(file, "intermediate") ||
497      hasParentDirectories(file, "advanced") ||
498      hasParentDirectories(file, "java7") ||
499      hasParentDirectories(file, "java8");
500    if (isInAKoansDirectory) {
501      this.isSubmittingKoans = true;
502      db(file + " is in a koans directory");
503    }
504    return isInAKoansDirectory;
505  }
506
507  @VisibleForTesting
508  boolean isInMavenProjectDirectory(File file) {
509    boolean isInMavenProjectDirectory = hasParentDirectories(file, userId, "cs410J", "pdx", "edu", "java|javadoc|resources", "main|test|it", "src");
510    if (isInMavenProjectDirectory) {
511      db(file + " is in the edu/pdx/cs410J directory");
512    }
513    return isInMavenProjectDirectory;
514  }
515
516  private boolean hasParentDirectories(File file, String... parentDirectoryNames) {
517    File parent = file.getParentFile();
518
519    // Skip over subpackages
520    while (parent != null && !parent.getName().equals(parentDirectoryNames[0])) {
521      parent = parent.getParentFile();
522    }
523
524    for (String parentDirectoryName : parentDirectoryNames) {
525      if (parent == null || !parent.getName().matches(parentDirectoryName)) {
526        return false;
527
528      } else {
529        parent = parent.getParentFile();
530      }
531    }
532
533    return true;
534  }
535
536  private List<String> fetchListOfFilesThatCanNotBeSubmitted() {
537    return fetchListOfStringsFromResource("no-submit");
538  }
539
540  /**
541   * Prints a summary of what is about to be submitted and prompts the
542   * user to verify that it is correct.
543   *
544   * @return <code>true</code> if the user wants to submit
545   */
546  private boolean verifySubmission(Set<File> sourceFiles) {
547    // Print out what is going to be submitted
548    out.print("\n" + userName);
549    out.print("'s submission for ");
550    out.println(projName);
551
552    for (File file : sourceFiles) {
553      out.println("  " + file);
554    }
555
556    if (comment != null) {
557      out.println("\nComment: " + comment + "\n\n");
558    }
559
560    out.println("A receipt will be sent to: " + userEmail + "\n");
561
562    warnIfMainProjectClassIsNotSubmitted(sourceFiles);
563
564    warnIfTestClassesAreNotSubmitted(sourceFiles);
565
566    askForEstimatedHours();
567
568    return doesUserWantToSubmit();
569  }
570
571  protected void warnIfMainProjectClassIsNotSubmitted(Set<File> sourceFiles) {
572    boolean wasMainProjectClassSubmitted = sourceFiles.stream().anyMatch((f) -> f.getName().contains(this.projName));
573    if (!wasMainProjectClassSubmitted && !this.isSubmittingKoans) {
574      String mainProjectClassName = this.projName + ".java";
575      out.println("*** WARNING: You are submitting " + this.projName +
576        ", but did not include " + mainProjectClassName + ".\n" +
577        "    You might want to check the name of the project or the files you are submitting.\n");
578    }
579  }
580
581  protected void warnIfTestClassesAreNotSubmitted(Set<File> sourceFiles) {
582    boolean wereTestClassessSubmitted = submittedTestClasses(sourceFiles);
583    if (!wereTestClassessSubmitted && !this.isSubmittingKoans) {
584      out.println("*** WARNING: You are not submitting a \"test\" directory.\n" +
585        "    Your unit tests are executed as part of the grading of your project.\n");
586    }
587  }
588
589  @VisibleForTesting
590  static boolean submittedTestClasses(Set<File> sourceFiles) {
591    return sourceFiles.stream().anyMatch((f) -> f.getPath().contains("test"));
592  }
593
594  private boolean doesUserWantToSubmit() {
595    while (true) {
596      out.print("\nDo you wish to continue with the submission? (yes/no) ");
597      out.flush();
598
599      try {
600        String line = in.readLine().trim();
601        switch (line) {
602          case "yes":
603            return true;
604
605          case "no":
606            return false;
607
608          default:
609            err.println("** Please enter yes or no");
610            break;
611        }
612
613      } catch (IOException ex) {
614        err.println("** Exception while reading from System.in: " + ex);
615      }
616    }
617  }
618
619  /**
620   * Returns the name of a <code>File</code> relative to the source
621   * directory.
622   */
623  protected String getZipEntryNameFor(File file) {
624    if (isSubmittingKoans) {
625      return file.getParentFile().getName() + "/" + file.getName();
626
627    } else {
628      // We already know that the file is in the correct directory
629      return getZipEntryNameFor(file.getPath());
630    }
631  }
632
633  @VisibleForTesting
634  static String getZipEntryNameFor(String filePath) {
635    Pattern pattern = Pattern.compile(".*/src/(main|test|it)/(java|javadoc|resources)/edu/pdx/cs410J/(.*)");
636    Matcher matcher = pattern.matcher(filePath);
637
638    if (matcher.matches()) {
639      return "src/" + matcher.group(1) + "/" + matcher.group(2) + "/edu/pdx/cs410J/" + matcher.group(3);
640    } else {
641      throw new IllegalStateException("Can't extract zip entry name for " + filePath);
642    }
643  }
644
645  /**
646   * Creates a Zip file that contains the source files.  The Zip File
647   * is temporary and is deleted when the program exits.
648   */
649  private File makeZipFileWith(Set<File> sourceFiles) throws IOException {
650    String zipFileName = userName.replace(' ', '_') + "-TEMP";
651    File zipFile = File.createTempFile(zipFileName, ".zip");
652    if (!saveZip) {
653      zipFile.deleteOnExit();
654
655    } else {
656      out.println("Saving temporary Zip file: " + zipFile);
657    }
658
659    db("Created Zip file: " + zipFile);
660
661    Map<File, String> sourceFilesWithNames =
662      sourceFiles.stream().collect(Collectors.toMap(file -> file, this::getZipEntryNameFor));
663
664    new ZipFileOfFilesMaker(sourceFilesWithNames, zipFile, getManifestEntries()).makeZipFile();
665    return zipFile;
666  }
667
668  private Map<Attributes.Name, String> getManifestEntries() {
669    Map<Attributes.Name, String> manifestEntries = new HashMap<>();
670    manifestEntries.put(ManifestAttributes.USER_NAME, userName);
671    manifestEntries.put(ManifestAttributes.USER_ID, userId);
672    manifestEntries.put(ManifestAttributes.USER_EMAIL, userEmail);
673    manifestEntries.put(ManifestAttributes.PROJECT_NAME, projName);
674    manifestEntries.put(ManifestAttributes.SUBMISSION_COMMENT, comment);
675    manifestEntries.put(ManifestAttributes.SUBMISSION_TIME, ManifestAttributes.formatSubmissionTime(submitTime));
676    if (estimatedHours != null) {
677      manifestEntries.put(ManifestAttributes.ESTIMATED_HOURS, String.valueOf(estimatedHours));
678    }
679    return manifestEntries;
680  }
681
682  /**
683   * Sends the Zip file to the TA as a MIME attachment.  Also includes
684   * a textual summary of the contents of the Zip file.
685   */
686  private void mailTA(File zipFile, Set<File> sourceFiles) throws MessagingException {
687    MimeMessage message =
688      newEmailTo(newEmailSession(debug), TA_EMAIL)
689        .from(userEmail, userName)
690        .withSubject("CS410J-SUBMIT " + userName + "'s " + projName)
691        .createMessage();
692
693    MimeBodyPart textPart = createTextPartOfTAEmail(sourceFiles);
694    MimeBodyPart filePart = createZipAttachment(zipFile);
695
696    Multipart mp = new MimeMultipart();
697    mp.addBodyPart(textPart);
698    mp.addBodyPart(filePart);
699
700    message.setContent(mp);
701
702    out.println("Submitting project to Grader");
703
704    Transport.send(message);
705  }
706
707  private MimeBodyPart createZipAttachment(File zipFile) throws MessagingException {
708    // Now attach the Zip file
709    DataSource ds = new FileDataSource(zipFile) {
710      @Override
711      public String getContentType() {
712        return "application/zip";
713      }
714    };
715    DataHandler dh = new DataHandler(ds);
716    MimeBodyPart filePart = new MimeBodyPart();
717
718    String zipFileTitle = getZipFileTitle();
719
720    filePart.setDataHandler(dh);
721    filePart.setFileName(zipFileTitle);
722    filePart.setDescription(userName + "'s " + projName);
723
724    return filePart;
725  }
726
727  @VisibleForTesting
728  String getZipFileTitle() {
729    return userName.replace(' ', '_') + ".zip";
730  }
731
732  private MimeBodyPart createTextPartOfTAEmail(Set<File> sourceFiles) throws MessagingException {
733    // Create the text portion of the message
734    StringBuilder text = new StringBuilder();
735    text.append("Student name: ").append(userName).append(" (").append(userEmail).append(")\n");
736    text.append("Project name: ").append(projName).append("\n");
737    text.append("Submitted on: ").append(humanReadableSubmitDate()).append("\n");
738    if (comment != null) {
739      text.append("\nComment: ").append(comment).append("\n\n");
740    }
741    text.append("Contents:\n");
742
743    for (File file : sourceFiles) {
744      text.append("  ").append(getZipEntryNameFor(file)).append("\n");
745    }
746    text.append("\n\n");
747
748    MimeBodyPart textPart = new MimeBodyPart();
749    textPart.setContent(text.toString(), "text/plain");
750
751    // Try not to display text as separate attachment
752    textPart.setDisposition("inline");
753    return textPart;
754  }
755
756  private String humanReadableSubmitDate() {
757    return submitTime.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL, FormatStyle.MEDIUM));
758  }
759
760  /**
761   * Sends a email to the user as a receipt of the submission.
762   */
763  private void mailReceipt(Set<File> sourceFiles) throws MessagingException {
764    String subject = "CS410J " + projName + " submission";
765    InternetAddress email = newInternetAddress(this.userEmail, this.userName);
766    MimeMessage message =
767      newEmailTo(newEmailSession(debug), email)
768        .from(TA_EMAIL)
769        .replyTo(DAVE_EMAIL)
770        .withSubject(subject)
771        .createMessage();
772
773    // Create the contents of the message
774    StringBuilder text = new StringBuilder();
775    text.append("On ").append(humanReadableSubmitDate()).append("\n");
776    text.append(userName).append(" (").append(userEmail).append(")\n");
777    text.append("submitted the following files for ").append(projName).append(":\n");
778
779    for (File file : sourceFiles) {
780      text.append("  ").append(file.getAbsolutePath()).append("\n");
781    }
782
783    if (comment != null) {
784      text.append("\nComment: ").append(comment).append("\n\n");
785    }
786
787    text.append("\n\n");
788    text.append("Have a nice day.");
789
790    // Add the text to the message and send it
791    message.setText(text.toString());
792    message.setDisposition("inline");
793
794    out.println("Sending receipt to you at " + userEmail);
795
796    Transport.send(message);
797  }
798
799  /////////////////////////  Main Program  ///////////////////////////
800
801  /**
802   * Prints usage information about this program.
803   */
804  protected void usage(String message) {
805    err.println("\n** " + message + "\n");
806    err.println("usage: java " + this.getClass().getSimpleName() + " [options] args file+");
807    err.println("  args are (in this order):");
808    err.println("    project            What project is being submitted (Project1, Project2, etc.)");
809    err.println("    studentXmlFile     XML file with info about who is submitting the project");
810    err.println("    srcDirectory       Directory containing source code to submit");
811    err.println("  options are (options may appear in any order):");
812    err.println("    -savezip           Saves temporary Zip file");
813    err.println("    -nosend            Generates zip file, but does not send emails");
814    err.println("    -smtp serverName   Name of SMTP server");
815    err.println("    -verbose           Log debugging output");
816    err.println("    -comment comment   Info for the Grader");
817    err.println("");
818    err.println("Submits Java source code to the CS410J grader.");
819    System.exit(1);
820  }
821
822  public static void main(String[] args) throws IOException, MessagingException {
823    Submit submit = new Submit();
824    submit.parseCommandLineAndSubmit(args);
825  }
826
827  /**
828   * Parses the command line, finds the source files, prompts the user
829   * to verify whether or not the settings are correct, and then sends
830   * an email to the Grader.
831   */
832  void parseCommandLineAndSubmit(String[] args) throws IOException, MessagingException {
833    // Parse the command line
834    for (int i = 0; i < args.length; i++) {
835      // Check for options first
836      if (args[i].equals("-smtp")) {
837        if (++i >= args.length) {
838          usage("No SMTP server specified");
839        }
840
841        this.setEmailServerHostName(args[i]);
842
843      } else if (args[i].equals("-verbose")) {
844        this.setDebug(true);
845
846      } else if (args[i].equals("-savezip")) {
847        this.saveZip();
848
849      } else if (args[i].equals("-nosend")) {
850        this.doNotSendEmails();
851
852      } else if (args[i].equals("-comment")) {
853        if (++i >= args.length) {
854          usage("No comment specified");
855        }
856
857        this.setComment(args[i]);
858
859      } else if (this.projName == null) {
860        this.setProjectName(args[i]);
861
862      } else if (this.studentXmlFileName == null) {
863        this.studentXmlFileName = args[i];
864
865      } else {
866        // The name of a source file
867        this.addFile(args[i]);
868      }
869    }
870
871    boolean submitted;
872
873    try {
874      // Make sure that user entered enough information
875      this.validate();
876
877      this.db("Command line successfully parsed.");
878
879      submitted = this.submit(true);
880
881    } catch (IllegalStateException ex) {
882      usage(ex.getMessage());
883      return;
884    }
885
886    // All done.
887    if (submitted) {
888      out.println(this.projName + " submitted successfully.  Thank you.");
889
890    } else {
891      out.println(this.projName + " not submitted.");
892    }
893  }
894
895  void setSendReceipt(boolean sendReceipt) {
896    this.sendReceipt = sendReceipt;
897
898  }
899
900  public void setEstimatedHours(Double estimatedHours) {
901    this.estimatedHours = estimatedHours;
902  }
903
904  static class ManifestAttributes {
905
906    static final Attributes.Name USER_NAME = new Attributes.Name("Submitter-User-Name");
907    static final Attributes.Name USER_ID = new Attributes.Name("Submitter-User-Id");
908    static final Attributes.Name USER_EMAIL = new Attributes.Name("Submitter-Email");
909    static final Attributes.Name PROJECT_NAME = new Attributes.Name("Project-Name");
910    static final Attributes.Name SUBMISSION_TIME = new Attributes.Name("Submission-Time");
911    static final Attributes.Name SUBMISSION_COMMENT = new Attributes.Name("Submission-Comment");
912    static final Attributes.Name ESTIMATED_HOURS = new Attributes.Name("Estimated-Hours");
913
914    private static final DateTimeFormatter LEGACY_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm:ss");
915    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_DATE_TIME;
916
917    static String formatSubmissionTime(LocalDateTime submitTime) {
918      return submitTime.format(DATE_TIME_FORMATTER);
919    }
920
921    static LocalDateTime parseSubmissionTime(String string) {
922      try {
923        return LocalDateTime.parse(string, DATE_TIME_FORMATTER);
924
925      } catch (DateTimeParseException ex) {
926        return LocalDateTime.parse(string, LEGACY_DATE_TIME_FORMATTER);
927      }
928    }
929  }
930
931  @VisibleForTesting
932  interface CurrentTimeProvider {
933    LocalDateTime getCurrentTime();
934  }
935}