Conversation
- Add Form Builder menu actions: - Sync Draft Grades to Classroom - Finalize & Return Grades to Classroom - Track last posted Classroom assignment per user for targeted grade sync - Parse latest scores from Form Responses sheet and map by student email - Patch StudentSubmissions with draftGrade or draft+assignedGrade via Classroom API - Optionally return TURNED_IN submissions during finalize flow - Add required Classroom roster/profile email OAuth scopes in appsscript.json
- Restrict quiz generation to MCQ type for fully auto-gradable flow - Keep image-enabled answer mode and question image support - Add Classroom grade sync actions (draft + finalize/return) - Harden image fetch validation and dialog HTML escaping - Update sample/data docs to reflect MCQ-only behavior
There was a problem hiding this comment.
Pull request overview
This PR adds grade-sync/auto-grading functionality between generated Google Forms quizzes and Google Classroom, while narrowing quiz generation to MCQ-only and updating samples/docs accordingly.
Changes:
- Add Classroom grade sync workflow (“Finalize & Return Grades to Classroom”) and installable form-submit auto-grading trigger.
- Restrict quiz generation to
MCQtype only (remove SA/PARA/MSQ generation paths) and update sample question set/docs. - Extend Apps Script OAuth scopes to support new Classroom roster/email + trigger capabilities.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/appsscript.json | Adds scopes needed for ScriptApp triggers and Classroom roster/email lookups. |
| src/Code.js | Implements auto-grade trigger + manual grade sync to Classroom; restricts quiz building to MCQ-only; stores Classroom targeting metadata. |
| sample/math_assignment1_questions.csv | Updates sample questions to match MCQ-only support. |
| sample/Questions.sheet-format.md | Updates sheet-format documentation to describe MCQ-only behavior. |
| README.md | Updates feature list to reflect MCQ-only support. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| formState[email] = { | ||
| score: score, | ||
| syncedAt: new Date().toISOString(), | ||
| submissionId: submission.id, | ||
| }; | ||
| state[formId] = formState; |
There was a problem hiding this comment.
AUTO_GRADE_STATE is keyed by (and stores) student email addresses in Script Properties. That’s persistent PII stored in project-wide properties and accessible to all script editors. If you only need a stable identifier for “first/latest” tracking, prefer storing by submission.userId (available on the Classroom submission) and avoid persisting emails where possible.
| const candidateSheet = ss | ||
| .getSheets() | ||
| .find((sheet) => /^form responses/i.test(sheet.getName())); |
There was a problem hiding this comment.
getLatestResponseScoresByEmail picks the first sheet whose name starts with “Form Responses”. If the spreadsheet has multiple response tabs (common after generating/posting multiple forms), this can sync grades from the wrong form. Prefer selecting the most recent matching responses sheet (e.g., last match) or deriving the correct responses sheet based on the stored form/assignment metadata.
| const candidateSheet = ss | |
| .getSheets() | |
| .find((sheet) => /^form responses/i.test(sheet.getName())); | |
| const responseSheets = ss | |
| .getSheets() | |
| .filter((sheet) => /^form responses/i.test(sheet.getName())); | |
| const candidateSheet = | |
| responseSheets.length > 0 ? responseSheets[responseSheets.length - 1] : null; |
| .getSheets() | ||
| .find((sheet) => /^form responses/i.test(sheet.getName())); | ||
| if (!candidateSheet) { | ||
| throw new Error("Response sheet not found (expected tab starting with 'Form Responses')."); | ||
| } | ||
|
|
||
| const values = candidateSheet.getDataRange().getDisplayValues(); | ||
| if (!values || values.length < 2) { | ||
| throw new Error("Response sheet has no data rows."); | ||
| } | ||
|
|
||
| const header = values[0].map((value) => String(value || "").trim()); | ||
| const emailIndex = header.findIndex((name) => /email/i.test(name)); | ||
| const scoreIndex = header.findIndex((name) => /score/i.test(name)); | ||
|
|
||
| if (emailIndex === -1 || scoreIndex === -1) { | ||
| throw new Error( | ||
| "Response sheet must include Email and Score columns. Open the linked quiz form and ensure scoring is enabled.", | ||
| ); | ||
| } | ||
|
|
||
| const byEmail = {}; | ||
| for (let rowIndex = 1; rowIndex < values.length; rowIndex++) { | ||
| const row = values[rowIndex]; | ||
| const email = String(row[emailIndex] || "").toLowerCase().trim(); | ||
| if (!email) continue; | ||
|
|
||
| const parsedScore = parseScoreCell(row[scoreIndex]); | ||
| if (parsedScore == null) continue; | ||
| byEmail[email] = parsedScore; |
There was a problem hiding this comment.
This function reads the entire response sheet via getDataRange().getDisplayValues(), which can be slow and memory-heavy on large classes/quizzes. Consider reading only the header row first to locate the Email/Score columns, then fetching just those two columns for the populated rows.
| .getSheets() | |
| .find((sheet) => /^form responses/i.test(sheet.getName())); | |
| if (!candidateSheet) { | |
| throw new Error("Response sheet not found (expected tab starting with 'Form Responses')."); | |
| } | |
| const values = candidateSheet.getDataRange().getDisplayValues(); | |
| if (!values || values.length < 2) { | |
| throw new Error("Response sheet has no data rows."); | |
| } | |
| const header = values[0].map((value) => String(value || "").trim()); | |
| const emailIndex = header.findIndex((name) => /email/i.test(name)); | |
| const scoreIndex = header.findIndex((name) => /score/i.test(name)); | |
| if (emailIndex === -1 || scoreIndex === -1) { | |
| throw new Error( | |
| "Response sheet must include Email and Score columns. Open the linked quiz form and ensure scoring is enabled.", | |
| ); | |
| } | |
| const byEmail = {}; | |
| for (let rowIndex = 1; rowIndex < values.length; rowIndex++) { | |
| const row = values[rowIndex]; | |
| const email = String(row[emailIndex] || "").toLowerCase().trim(); | |
| if (!email) continue; | |
| const parsedScore = parseScoreCell(row[scoreIndex]); | |
| if (parsedScore == null) continue; | |
| byEmail[email] = parsedScore; | |
| .getSheets() | |
| .find((sheet) => /^form responses/i.test(sheet.getName())); | |
| if (!candidateSheet) { | |
| throw new Error("Response sheet not found (expected tab starting with 'Form Responses')."); | |
| } | |
| const lastRow = candidateSheet.getLastRow(); | |
| const lastColumn = candidateSheet.getLastColumn(); | |
| if (lastRow < 1 || lastColumn < 1) { | |
| throw new Error("Response sheet has no data rows."); | |
| } | |
| // Read only the header row to locate Email and Score columns. | |
| const headerRowValues = candidateSheet | |
| .getRange(1, 1, 1, lastColumn) | |
| .getDisplayValues()[0]; | |
| const header = headerRowValues.map((value) => String(value || "").trim()); | |
| const emailIndex = header.findIndex((name) => /email/i.test(name)); | |
| const scoreIndex = header.findIndex((name) => /score/i.test(name)); | |
| if (emailIndex === -1 || scoreIndex === -1) { | |
| throw new Error( | |
| "Response sheet must include Email and Score columns. Open the linked quiz form and ensure scoring is enabled.", | |
| ); | |
| } | |
| // Require at least one data row below the header. | |
| if (lastRow < 2) { | |
| throw new Error("Response sheet has no data rows."); | |
| } | |
| const emailColumn = emailIndex + 1; | |
| const scoreColumn = scoreIndex + 1; | |
| const dataRowCount = lastRow - 1; | |
| // Read only the Email and Score columns for populated rows. | |
| const emailValues = candidateSheet | |
| .getRange(2, emailColumn, dataRowCount, 1) | |
| .getDisplayValues(); | |
| const scoreValues = candidateSheet | |
| .getRange(2, scoreColumn, dataRowCount, 1) | |
| .getDisplayValues(); | |
| const byEmail = {}; | |
| for (let offset = 0; offset < dataRowCount; offset++) { | |
| const rawEmail = emailValues[offset][0]; | |
| const email = String(rawEmail || "").toLowerCase().trim(); | |
| if (!email) continue; | |
| const rawScore = scoreValues[offset][0]; | |
| const parsedScore = parseScoreCell(rawScore); | |
| if (parsedScore == null) continue; | |
| byEmail[email] = parsedScore; |
|
|
||
| ui.alert( | ||
| actionLabel, | ||
| `Done.\n\nPatched grades: ${patched}\nReturned submissions: ${returned}\nSkipped (no email mapping): ${skippedNoEmail}\nSkipped (no score): ${skippedNoScore}\nSkipped (state not TURNED_IN): ${skippedState}`, |
There was a problem hiding this comment.
The alert label says “Skipped (state not TURNED_IN)”, but skippedState only increments when the state is neither TURNED_IN nor RETURNED. Consider adjusting the message to reflect the actual condition (e.g., “not TURNED_IN or already RETURNED”) to avoid confusion when interpreting results.
| `Done.\n\nPatched grades: ${patched}\nReturned submissions: ${returned}\nSkipped (no email mapping): ${skippedNoEmail}\nSkipped (no score): ${skippedNoScore}\nSkipped (state not TURNED_IN): ${skippedState}`, | |
| `Done.\n\nPatched grades: ${patched}\nReturned submissions: ${returned}\nSkipped (no email mapping): ${skippedNoEmail}\nSkipped (no score): ${skippedNoScore}\nSkipped (state neither TURNED_IN nor RETURNED): ${skippedState}`, |
| - Supports **MCQ** question type (one correct answer) | ||
| - **Image support** | ||
| - `ImageURL` embeds a question image | ||
| - `AnswerAImageURL..AnswerDImageURL` enables image-choice mode for MCQ/MSQ (labeled A-D images + selectable A-D choices) |
There was a problem hiding this comment.
README still mentions image-choice mode for “MCQ/MSQ”, but MSQ support has been removed in this PR. Update this bullet to refer to MCQ-only so the feature list matches current capabilities.
| - `AnswerAImageURL..AnswerDImageURL` enables image-choice mode for MCQ/MSQ (labeled A-D images + selectable A-D choices) | |
| - `AnswerAImageURL..AnswerDImageURL` enables image-choice mode for MCQ (labeled A-D images + selectable A-D choices) |
| | **AnswerA..D** | ✅ | Used for `MCQ`/`MSQ`. Leave blanks if not needed (e.g., for `SA`, `PARA`). | | ||
| | **AnswerA..D** | ✅ | Used for `MCQ`. Provide at least 2 options. | | ||
| | **ImageURL** | ❌ | Optional. A public URL to an image. If provided, an image is inserted before the question in the form. | | ||
| | **AnswerAImageURL..AnswerDImageURL** | ❌ | Optional. For MCQ/MSQ, each URL displays an image item labeled A..D before the question. | |
There was a problem hiding this comment.
This table row still says answer-image URLs are optional “For MCQ/MSQ”, but MSQ is no longer supported. Adjust the wording to MCQ-only to keep the sheet-format docs consistent with the code.
| | **AnswerAImageURL..AnswerDImageURL** | ❌ | Optional. For MCQ/MSQ, each URL displays an image item labeled A..D before the question. | | |
| | **AnswerAImageURL..AnswerDImageURL** | ❌ | Optional. For MCQ, each URL displays an image item labeled A..D before the question. | |
| function ensureAutoGradeTriggerForForm(formId) { | ||
| const triggers = ScriptApp.getProjectTriggers(); | ||
| const hasTrigger = triggers.some( | ||
| (trigger) => | ||
| trigger.getHandlerFunction() === "handleAutoGradeFormSubmit" && | ||
| trigger.getTriggerSource() === ScriptApp.TriggerSource.FORMS && | ||
| trigger.getTriggerSourceId() === formId, | ||
| ); | ||
| if (hasTrigger) return; | ||
|
|
||
| ScriptApp.newTrigger("handleAutoGradeFormSubmit") | ||
| .forForm(formId) | ||
| .onFormSubmit() | ||
| .create(); | ||
| } |
There was a problem hiding this comment.
Auto-grading trigger creates one installable onFormSubmit trigger per form but never removes triggers for old/deleted forms. Over time this can hit Apps Script trigger quotas and leave orphan triggers firing unnecessarily. Consider adding cleanup (e.g., remove triggers for forms no longer in AUTO_GRADE_TARGETS) or reusing a single trigger strategy if possible.
| const attemptMode = target.attemptMode || "latest"; | ||
| const state = readAutoGradeState(); | ||
| const formState = state[formId] || {}; | ||
|
|
||
| if (attemptMode === "first" && formState[email]) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
handleAutoGradeFormSubmit does a read-modify-write on AUTO_GRADE_STATE without any locking. If multiple submissions arrive close together, concurrent trigger executions can overwrite each other and break the “first attempt” logic (or lose sync history). Use LockService.getScriptLock() (or similar) around the state update to make it atomic.
|
@copilot open a new pull request to apply changes based on the comments in this thread |
No description provided.