grading
Version:
Grading of student submissions, in particular programming tests.
341 lines (300 loc) • 13.6 kB
text/typescript
import { OptionValues } from 'commander';
import { copyFileSync } from 'fs';
import * as fs from 'fs/promises';
import { readFile, writeFile } from 'fs/promises';
import path from 'path';
import { loadTableWithEncoding } from '../csv';
import { createDir, fileExists, folderExists, readDir } from '../fsUtil';
import { GradingSchema, readGradingSchema } from '../grade/gradingschema';
import { Table } from '../table';
import { error, generateFileName, log, verb, verbosity, warn } from './cliUtil';
import { writeSettings } from './settings';
import { MANUAL_CONCLUSION_SCHEMA_URL, MANUAL_CORRECTIONS_SCHEMA_URL } from '../constants';
/**
* Location of default files in this package.
*/
const DEFAULT_FILES = "defaultFiles";
export async function cmdInit(options: OptionValues) {
verbosity(options);
try {
const schemaFile = options.gradingSchemaFile;
let gradingSchema: GradingSchema | undefined = undefined;
if (await fileExists(schemaFile)) {
gradingSchema = await readGradingSchema(schemaFile);
}
if (options.generateCorrectionsFile) {
const latestResultFile = await findLatestResultFile(options.resultFile,
{ examName: gradingSchema?.exam }
);
let results: Table | undefined = undefined;
if (latestResultFile) {
results = await loadTableWithEncoding(latestResultFile, options.encoding, options.resultCSVDelimiter);
} else {
log(`No result file found for exam ${gradingSchema?.exam}, cannot prefill users in corrections file. Tip: generate a result file first (by running the grade command)!`)
}
gradingSchema = await readGradingSchema(schemaFile);
await writeCorrectionsFile(options.manualCorrectionsFile, options, gradingSchema, results);
if (gradingSchema) {
verb("Corrections file created, skip other steps since grading schema is already available.");
return;
}
}
if (options.generateManualConclusionFile) {
const latestResultFile = await findLatestResultFile(options.resultFile,
{ examName: gradingSchema?.exam }
);
let results: Table | undefined = undefined;
if (latestResultFile) {
results = await loadTableWithEncoding(latestResultFile, options.encoding, options.resultCSVDelimiter);
}
await writeConclusionFile(options.manualConclusionFile, options, gradingSchema, results);
if (gradingSchema) {
verb("Conclusion file created, skip other steps.");
return;
}
}
const checkDir = options.checkDir;
if (! await folderExists(checkDir, 'checkDir')) {
await createDir(checkDir);
} else {
log(`Folder ${checkDir} already exists.`)
}
const packageName = "grading"
const baseStart = __dirname.lastIndexOf(packageName);
if (baseStart < 0) {
program.error("Unable to find grading package folder.");
}
const packageFile = __dirname.substring(0, baseStart + packageName.length);
const defaultFiles = await readDir(path.join(packageFile, DEFAULT_FILES), 'file', true);
for (const defaultFile of defaultFiles) {
const srcFile = path.join(packageFile, DEFAULT_FILES, defaultFile);
const targetFile = path.join(checkDir, defaultFile);
const targetDir = path.dirname(targetFile);
if (targetDir != '.') {
await createDir(targetDir);
}
if (await fileExists(targetFile)) {
log(`File ${targetFile} already exists, skipped.`);
} else {
verb(`Copy ${defaultFile}`)
copyFileSync(srcFile, targetFile);
}
}
log(`Default files created in ${checkDir}`)
// .option('--stdInFolder', 'Name of folder to be created for input files (and added to .gitignore).', 'gradingIn')
// .option('--stdOutFolder', 'Name of folder to be created for output files (and added to .gitignore).', 'gradingOut')
if (!options.noFolders) {
if (await folderExists(options.stdInFolder, 'stdInFolder')) {
log(`${options.stdInFolder} already exists.`);
} else {
await createDir(options.stdInFolder);
log(`Created ${options.stdInFolder} where you should place files downloaded from Moodle.`);
}
}
if (!options.noGitignore) {
if (!await fileExists('.gitignore')) {
log(`.gitignore does not exists, please ensure to add ${options.stdInFolder} and ${options.stdOutFolder} manually.`)
} else {
let gitignore = (await readFile('.gitignore')).toString();
const addIn = !new RegExp(`^${options.stdInFolder}$`, 'm').test(gitignore);
const addOut = !new RegExp(`^${options.stdOutFolder}$`, 'm').test(gitignore);
if (addIn || addOut) {
if (gitignore.charAt(gitignore.length - 1) != '\n') {
gitignore += "\n";
}
gitignore += "# Grading input and output folders\n"
if (addIn) gitignore += `${options.stdInFolder}\n`
if (addOut) gitignore += `${options.stdOutFolder}\n`
await writeFile('.gitignore', gitignore)
if (addIn && addOut) {
log(`Added ${options.stdInFolder} and${options.stdOutFolder} to .gitignore`);
} else if (addIn) {
log(`Added ${options.stdInFolder} to .gitignore, ${options.stdOutFolder} already present`);
} else if (addOut) {
log(`Added ${options.stdOutFolder} to .gitignore, ${options.stdInFolder} already present`);
}
} else {
log(`${options.stdInFolder} and ${options.stdOutFolder} already found in .gitignore`);
}
}
}
if (options.generateSettings) {
writeSettings();
}
log(`
In order to not run into problems in your (solution) project, add the following entries to your configuration settings (besides .gitignore):
- tsconfig.ts: add "include": ["src/**/*"] at the end if not already present
- jest.config.js: add gradingOut to ignored folders in order to prevent testing of submissions in your daily work, i.e. add the following lines:
testPathIgnorePatterns: ["<rootDir>/dist/", "<rootDir>/node_modules/", "<rootDir>/gradingOut/"],
modulePathIgnorePatterns: ["<rootDir>/gradingOut/"],
coveragePathIgnorePatterns: ["<rootDir>/gradingOut/"]
`)
} catch (err) {
error(`${SEP}`)
// error(`${SEP}\nError: ${err}`);
program.error(String(err));
}
}
async function writeCorrectionsFile(manualCorrectionsFile: string, options: OptionValues,
gradingSchema?: GradingSchema, results?: Table) {
if (!manualCorrectionsFile) {
error("No manualCorrectionsFile provided, cannot generate initial correction file");
return;
}
const correctionFileName = generateFileName(manualCorrectionsFile, options);
if (await fileExists(correctionFileName)) {
log(`Corrections file '${correctionFileName}' already exists, do not overwrite.`)
return;
}
const folder = path.dirname(correctionFileName);
if (!await folderExists(folder)) {
await createDir(folder, true);
}
const taskInGeneratedCorrectionsFile = Boolean(options.taskInGeneratedCorrectionsFile);
let sampleTasks: string;
if (gradingSchema) {
sampleTasks = gradingSchema.tasks.map(task => `{ "name": "${task.name}", "points": 0, "reason": "" }`).join(",");
} else {
sampleTasks = `
{
"name": "Task name",
"points": 0,
"reason": ""
}`;
}
let userList = "";
if (results) {
userList = results.rawArray.map(row => "\n"
+ ` // { "submissionID": "${row[3]}", "userName": "${row[4]}", /* ${row[7]} */ "general": [{"points": 0, "reason": "" }]`
+ (taskInGeneratedCorrectionsFile ? `, "tasks": [${sampleTasks}]` : "")
+ "}")
.filter((_, index) => index > 0).join(",");
}
const content = `{
"$schema": "${MANUAL_CORRECTIONS_SCHEMA_URL}",
"exam": "${gradingSchema?.exam || "Blatt x"}",
"course": "${gradingSchema?.course || "Course"}",
"term": "${gradingSchema?.term || computeTerm()}",
"corrections": [${userList}
/*
{
"submissionID": "xxx", "userName": "first last",
"general": [
{
"points": 0,
"reason": "General remarks or points."
}
],
"tasks": [${sampleTasks}
]
},
*/
]
}`;
log(`Write manual correction file ${correctionFileName}`);
await fs.writeFile(correctionFileName, content, 'utf8');
}
function findEndOfBlock(s: string, offset: number) {
let counter = 0;
for (let i = offset + 1; i < s.length; i++) {
if (s[i] == '}') {
counter--;
if (counter < 0) {
return i;
}
} else if (s[i] == '{') {
counter++;
}
}
return -1;
}
async function findLatestResultFile(resultFileTemplate: string, templateVars: { examName?: string }) {
const resultFiles = await findResultFiles(resultFileTemplate, templateVars);
if (resultFiles.length == 0) {
return undefined;
}
return resultFiles[resultFiles.length - 1];
}
async function findResultFiles(resultFileTemplate: string, templateVars: { examName?: string }) {
const resultFileName = generateFileName(resultFileTemplate, templateVars, false);
verb(`Search for latest result file ${resultFileName}`);
const ext = path.extname(resultFileName);
if (!ext || ext.includes('${')) {
log(`Cannot find latest result file, extension not found or it contains unresolved template variables: ${ext}`);
return [];
}
const resultsDir = path.dirname(resultFileName);
const base = path.basename(resultFileName, ext);
if (resultsDir.includes('${')) {
log(`Cannot find latest result file, directory name contains unresolved template variables: ${resultsDir}`);
return [];
}
const prefixIndex = base.indexOf('${');
const namePrefix = (prefixIndex >= 0) ? base.substring(0, prefixIndex) : base;
const resultFiles = (await readDir(resultsDir, 'file', false, 'All results folder.'))
.filter(entry => {
return (path.basename(entry).startsWith(namePrefix) && path.extname(entry) === ext)
})
.sort((a, b) => a.localeCompare(b))
.map(entry => path.join(resultsDir, entry));
if (resultFiles.length == 0) {
verb(`No result files found matching ${resultsDir}/${namePrefix}.${ext}`);
}
return resultFiles;
}
function computeTerm() {
const now = new Date();
const year = now.getFullYear() - 2000;
const month = now.getMonth();
if (month >= 4 && month <= 9) { // Summer term
return `SS 20${year}`;
} else { // Winter term
if (month < 4) { // Winter term of previous year
return `WS 20${year - 1}/${year}`;
} else { // Winter term of current year
return `WS 20${year}/${year + 1}`;
}
}
}
async function writeConclusionFile(manualConclusionFile: string,
options: OptionValues, gradingSchema?: GradingSchema, results?: Table) {
if (!manualConclusionFile) {
error("No manualConclusionFile provided, cannot generate initial conclusion file");
return;
}
const conclusionFileName = generateFileName(manualConclusionFile, { resultsDir: options.resultsDir, stdInFolder: options.stdInFolder });
if (await fileExists(conclusionFileName)) {
warn(`Conclusion file '${conclusionFileName}' already exists, do not overwrite.`)
return;
}
const folder = path.dirname(conclusionFileName);
if (!await folderExists(folder)) {
error(`Conclusion file path '${folder}' does not exists (and it is not automatically created).`)
return;
}
let userList = "";
if (results) {
userList = results.rawArray.map(row => `
// {"userName": "${row[4]}", "totalGrading": 0.0, "gradingReason": "", "generalRemark": "" }`).filter((_, index) => index > 0).join(",");
}
const content = `{
"$schema": "${MANUAL_CONCLUSION_SCHEMA_URL}",
"course": "${gradingSchema?.course || "Course"}",
"term": "${gradingSchema?.term || computeTerm()}",
"conclusions": [${userList}
/*
{
"userName": "first last",
"totalGrading": 0.0,
"gradingReason": "Reason for total grading.",
"generalRemark": "General remark."
},
*/
],
"aliases": [ // for renamed students (or names changed otherwise, e.g., typos)
// ["currentFirst currentLast", "oldFirst oldLast"],]]
]
}`;
log(`Write manual conclusion file ${conclusionFileName}`);
await fs.writeFile(conclusionFileName, content, 'utf8');
}