CoolFace
Apppublic

mynewi/GradingApp

sourceHugging Faceupdated 28d agoView on Hugging Face
0likes
app.js715 linesDownload Raw Back to root
1'use strict';2 3pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';4 5const STORAGE_KEY = 'academicEvaluatorProV3_state';6const DB_NAME = 'AcademicEvaluatorProV3DB';7const DB_STORE = 'localObjects';8const APP_VERSION = 3;9const EPS = 0.001;10 11const clone = obj => typeof structuredClone === 'function' ? structuredClone(obj) : JSON.parse(JSON.stringify(obj));12const uid = (prefix='id') => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2,8)}`;13const num = (v, fallback=0) => Number.isFinite(Number(v)) ? Number(v) : fallback;14const round2 = v => Math.round((Number(v) + Number.EPSILON) * 100) / 100;15const esc = value => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));16const nowIso = () => new Date().toISOString();17const sameId = (a,b) => String(a||'').trim().toLowerCase() === String(b||'').trim().toLowerCase();18 19function defaultConfiguration(){20  return {21    id:'config-default',22    name:'Default 3-Assignment Configuration',23    code:'',24    version:1,25    createdAt:nowIso(),26    updatedAt:nowIso(),27    assignments:[28      {29        id:'assignment-reflection',30        name:'Journal Reflection - Individual Assignment',31        type:'individual',32        weight:30,33        criteria:[34          {id:'jr-1',name:'Understanding & Critical Analysis',max:30},35          {id:'jr-2',name:'Reflection & Learning',max:25},36          {id:'jr-3',name:'Application to Practice',max:20},37          {id:'jr-4',name:'Use of Evidence / Literature',max:15},38          {id:'jr-5',name:'Academic Writing & Referencing',max:10}39        ]40      },41      {42        id:'assignment-group',43        name:'Group Assignment',44        type:'group',45        weight:40,46        criteria:[47          {id:'ga-1',name:'Understanding of the Topic / Problem',max:20},48          {id:'ga-2',name:'Analysis & Integration',max:25},49          {id:'ga-3',name:'Framework / Solution Development',max:20},50          {id:'ga-4',name:'Practical Application',max:15},51          {id:'ga-5',name:'Use of Academic Evidence',max:10},52          {id:'ga-6',name:'Report Quality & Referencing',max:10}53        ]54      },55      {56        id:'assignment-final',57        name:'Final Individual Assignment',58        type:'individual',59        weight:30,60        criteria:[61          {id:'fi-1',name:'Problem / Topic Definition & Justification',max:20},62          {id:'fi-2',name:'Literature / Conceptual Foundation',max:20},63          {id:'fi-3',name:'Critical Analysis & Application',max:30},64          {id:'fi-4',name:'Recommendations / Conclusions',max:20},65          {id:'fi-5',name:'Academic Writing & Referencing',max:10}66        ]67      }68    ]69  };70}71 72function provisionalBatch(config){73  return {74    id:uid('batch'),75    name:'',76    initialized:false,77    configId:config.id,78    configVersion:config.version,79    configSnapshot:clone(config),80    createdAt:nowIso()81  };82}83 84function initialState(){85  const config=defaultConfiguration();86  const batch=provisionalBatch(config);87  return {88    appVersion:APP_VERSION,89    configurations:{[config.id]:config},90    batches:{[batch.id]:batch},91    activeConfigId:config.id,92    activeBatchId:batch.id,93    activeBatchByConfig:{[config.id]:batch.id},94    activeAssignmentId:config.assignments[0].id,95    records:[],96    drafts:{},97    settings:{persistenceMode:'browser'}98  };99}100 101let state=initialState();102let editingRecordId=null;103let configEditId=null;104let configWorkingCopy=null;105let pendingBatchAction=null;106let pendingBatchMode='first-save';107let pendingGroupRows=null;108let toastTimer=null;109 110// Viewer state111let isDocumentLoaded=false;112let loadedDocumentKey=null;113let pdfDoc=null;114let pageNum=1;115let pageRendering=false;116let pageNumPending=null;117let pdfScale=1.2;118let pdfScrollAfterRender=null;119 120const canvas=document.getElementById('pdf-canvas');121const ctx=canvas.getContext('2d');122const canvasWrapper=document.getElementById('canvas-wrapper');123 124// ---------- IndexedDB ----------125function openLocalDB(){126  return new Promise((resolve,reject)=>{127    const req=indexedDB.open(DB_NAME,1);128    req.onupgradeneeded=()=>{const db=req.result;if(!db.objectStoreNames.contains(DB_STORE))db.createObjectStore(DB_STORE)};129    req.onsuccess=()=>resolve(req.result);130    req.onerror=()=>reject(req.error);131  });132}133async function idbSet(key,value){const db=await openLocalDB();return new Promise((resolve,reject)=>{const tx=db.transaction(DB_STORE,'readwrite');tx.objectStore(DB_STORE).put(value,key);tx.oncomplete=()=>{db.close();resolve()};tx.onerror=()=>{db.close();reject(tx.error)}})}134async function idbGet(key){const db=await openLocalDB();return new Promise((resolve,reject)=>{const tx=db.transaction(DB_STORE,'readonly');const req=tx.objectStore(DB_STORE).get(key);req.onsuccess=()=>{db.close();resolve(req.result)};req.onerror=()=>{db.close();reject(req.error)}})}135async function idbDelete(key){const db=await openLocalDB();return new Promise((resolve,reject)=>{const tx=db.transaction(DB_STORE,'readwrite');tx.objectStore(DB_STORE).delete(key);tx.oncomplete=()=>{db.close();resolve()};tx.onerror=()=>{db.close();reject(tx.error)}})}136 137// ---------- State ----------138function saveState(){139  try{140    state.appVersion=APP_VERSION;141    localStorage.setItem(STORAGE_KEY,JSON.stringify(state));142    setAutosaveStatus('✓ Saved');143  }catch(err){console.error(err);showToast('Browser storage failed. Save a session backup file now.','error')}144}145function loadState(){146  const raw=localStorage.getItem(STORAGE_KEY);147  if(!raw){state=initialState();saveState();return}148  try{149    const parsed=JSON.parse(raw);150    if(parsed?.configurations && parsed?.batches && Array.isArray(parsed?.records))state=parsed;151    else state=initialState();152  }catch{state=initialState()}153  ensureStateIntegrity();154}155function ensureStateIntegrity(){156  state.configurations ||= {};157  state.batches ||= {};158  state.activeBatchByConfig ||= {};159  state.records ||= [];160  state.drafts ||= {};161  state.settings ||= {}; state.settings.persistenceMode='browser'; delete state.settings.folderName; delete state.settings.lastFolderSync;162  const configIds=Object.keys(state.configurations);163  if(!configIds.length){state=initialState();return}164  if(!state.configurations[state.activeConfigId])state.activeConfigId=configIds[0];165  let bid=state.activeBatchByConfig[state.activeConfigId];166  if(!bid || !state.batches[bid]){167    const candidates=Object.values(state.batches).filter(b=>b.configId===state.activeConfigId).sort((a,b)=>new Date(b.createdAt)-new Date(a.createdAt));168    if(candidates.length)bid=candidates[0].id;169    else{170      const batch=provisionalBatch(state.configurations[state.activeConfigId]);state.batches[batch.id]=batch;bid=batch.id;171    }172    state.activeBatchByConfig[state.activeConfigId]=bid;173  }174  state.activeBatchId=bid;175  const cfg=batchConfig();176  if(!cfg?.assignments?.some(a=>a.id===state.activeAssignmentId))state.activeAssignmentId=cfg?.assignments?.[0]?.id||null;177}178 179function activeBaseConfig(){return state.configurations[state.activeConfigId]}180function activeBatch(){return state.batches[state.activeBatchId]}181function batchConfig(){return activeBatch()?.configSnapshot || activeBaseConfig()}182function activeAssignment(){return batchConfig()?.assignments?.find(a=>a.id===state.activeAssignmentId) || batchConfig()?.assignments?.[0]}183function batchRecords(){return state.records.filter(r=>r.batchId===state.activeBatchId)}184function rubricMax(a){return round2((a?.criteria||[]).reduce((s,c)=>s+num(c.max),0))}185function configWeightTotal(cfg){return round2((cfg?.assignments||[]).reduce((s,a)=>s+num(a.weight),0))}186 187function currentDraft(){188  const bid=state.activeBatchId,aid=state.activeAssignmentId;189  state.drafts[bid] ||= {};190  state.drafts[bid][aid] ||= {studentName:'',studentId:'',groupNumber:'',scores:{},touched:{},feedback:'',documentKey:null,fileName:'',fileType:'',pageNum:1,pdfScale:1.2};191  return state.drafts[bid][aid];192}193 194// ---------- Initialization ----------195document.addEventListener('DOMContentLoaded',async()=>{196  loadState();197  bindEvents();198  renderWorkspaceSelectors();199  renderGradingPanel();200  updateBatchChip();201  updateBackupUI();202  await restoreDraftDocument();203});204 205function bindEvents(){206  document.getElementById('upload-btn').onclick=()=>document.getElementById('file-upload').click();207  document.getElementById('empty-upload-btn').onclick=()=>document.getElementById('file-upload').click();208  document.getElementById('file-upload').addEventListener('change',e=>{const f=e.target.files[0];e.target.value='';if(f)handleDocumentUpload(f)});209  document.getElementById('config-btn').onclick=openConfigModal;210  document.getElementById('new-batch-btn').onclick=openNewBatchModal;211  document.getElementById('gradebook-btn').onclick=openGradebook;212  document.getElementById('export-btn').onclick=exportGradebookXlsx;213  document.getElementById('backup-btn').onclick=()=>{updateBackupUI();openModal('backup-modal')};214 215  document.getElementById('active-config').addEventListener('change',e=>switchConfig(e.target.value));216  document.getElementById('active-assignment').addEventListener('change',e=>switchAssignment(e.target.value));217  ['student-name','student-id','group-number','lecturer-feedback'].forEach(id=>document.getElementById(id).addEventListener('input',captureDraftFromUI));218  document.getElementById('save-record-btn').onclick=prepareSaveStudentRecord;219  document.getElementById('group-import-open').onclick=()=>{resetGroupImportResult();openModal('group-import-modal')};220 221  // PDF controls and restored scroll-page behavior222  document.getElementById('pdf-prev').onclick=()=>goPdfPage(-1);223  document.getElementById('pdf-next').onclick=()=>goPdfPage(1);224  document.getElementById('pdf-zoom-out').onclick=zoomOutPdf;225  document.getElementById('pdf-zoom-in').onclick=zoomInPdf;226  document.getElementById('drop-paper-pdf').onclick=()=>clearDocument(true);227  canvasWrapper.addEventListener('wheel',e=>{228    if(!pdfDoc)return;229    if(e.ctrlKey){e.preventDefault();e.deltaY<0?zoomInPdf():zoomOutPdf();return}230    const atBottom=canvasWrapper.scrollTop+canvasWrapper.clientHeight>=canvasWrapper.scrollHeight-4;231    const atTop=canvasWrapper.scrollTop<=4;232    if(e.deltaY>0 && atBottom && pageNum<pdfDoc.numPages){e.preventDefault();goPdfPage(1)}233    else if(e.deltaY<0 && atTop && pageNum>1){e.preventDefault();goPdfPage(-1)}234  },{passive:false});235 236  document.querySelectorAll('[data-close]').forEach(btn=>btn.onclick=()=>closeModal(btn.dataset.close));237  document.querySelectorAll('.modal-overlay').forEach(overlay=>overlay.addEventListener('mousedown',e=>{if(e.target===overlay && overlay.id!=='first-batch-modal')closeModal(overlay.id)}));238 239  // Configuration240  document.getElementById('new-config-btn').onclick=createNewConfigWorkingCopy;241  document.getElementById('duplicate-config-btn').onclick=duplicateSelectedConfig;242  document.getElementById('delete-config-btn').onclick=deleteSelectedConfig;243  document.getElementById('save-config-btn').onclick=saveConfigurationFromEditor;244  document.getElementById('add-assignment-btn').onclick=addAssignmentToWorkingCopy;245  document.getElementById('config-name').addEventListener('input',()=>{if(configWorkingCopy){configWorkingCopy.name=document.getElementById('config-name').value;renderConfigSidebar()}});246  document.getElementById('config-code').addEventListener('input',()=>{if(configWorkingCopy)configWorkingCopy.code=document.getElementById('config-code').value});247 248  // Batch setup249  document.getElementById('first-batch-cancel').onclick=()=>{pendingBatchAction=null;closeModal('first-batch-modal')};250  document.getElementById('first-batch-browser').onclick=completeFirstBatchSetup;251  document.getElementById('create-batch-btn').onclick=createNewBatch;252 253  // Group import254  document.getElementById('download-group-template').onclick=downloadGroupTemplate;255  document.getElementById('group-file-input').addEventListener('change',handleGroupFile);256  document.getElementById('import-pasted-group').onclick=importPastedGroupRows;257 258  // Gradebook259  document.getElementById('gradebook-search').addEventListener('input',renderGradebook);260  document.getElementById('gradebook-sort').addEventListener('change',renderGradebook);261  document.getElementById('gradebook-refresh').onclick=renderGradebook;262  document.getElementById('gradebook-export').onclick=exportGradebookXlsx;263  document.getElementById('print-reports').onclick=printAllStudentReports;264 265  // Backup266  document.getElementById('export-backup-btn').onclick=()=>downloadJson(`Academic_Evaluator_Session_Backup_${new Date().toISOString().slice(0,10)}.json`,buildBackupPayload());267  document.getElementById('restore-backup-file').addEventListener('change',restoreBackupFromFile);268}269 270// ---------- Workspace ----------271function renderWorkspaceSelectors(){272  const configSelect=document.getElementById('active-config');273  configSelect.innerHTML=Object.values(state.configurations).sort((a,b)=>a.name.localeCompare(b.name)).map(c=>`<option value="${esc(c.id)}" ${c.id===state.activeConfigId?'selected':''}>${esc(c.name)}</option>`).join('');274  const cfg=batchConfig();275  const assignmentSelect=document.getElementById('active-assignment');276  assignmentSelect.innerHTML=(cfg?.assignments||[]).map(a=>`<option value="${esc(a.id)}" ${a.id===state.activeAssignmentId?'selected':''}>${esc(a.name)}</option>`).join('');277  updateBatchChip();278}279 280function ensureBatchForConfig(configId){281  let bid=state.activeBatchByConfig[configId];282  if(bid && state.batches[bid])return state.batches[bid];283  const latest=Object.values(state.batches).filter(b=>b.configId===configId).sort((a,b)=>new Date(b.createdAt)-new Date(a.createdAt))[0];284  if(latest){state.activeBatchByConfig[configId]=latest.id;return latest}285  const batch=provisionalBatch(state.configurations[configId]);286  state.batches[batch.id]=batch;state.activeBatchByConfig[configId]=batch.id;return batch;287}288 289function switchConfig(configId){290  captureDraftFromUI();291  state.activeConfigId=configId;292  const batch=ensureBatchForConfig(configId);293  state.activeBatchId=batch.id;294  state.activeAssignmentId=batch.configSnapshot.assignments[0]?.id||null;295  editingRecordId=null;296  saveState();297  clearDocument(false);298  renderWorkspaceSelectors();renderGradingPanel();restoreDraftDocument();299}300function switchAssignment(assignmentId){301  captureDraftFromUI();state.activeAssignmentId=assignmentId;editingRecordId=null;saveState();clearDocument(false);renderGradingPanel();restoreDraftDocument();302}303function updateBatchChip(){304  const batch=activeBatch();const el=document.getElementById('current-batch-chip');305  if(!el)return;306  el.textContent=batch?.initialized&&batch.name?`Batch: ${batch.name}`:'Batch will be set on first save';307}308 309// ---------- Grading ----------310function renderGradingPanel(){311  const assignment=activeAssignment();if(!assignment)return;312  const draft=currentDraft();313  document.getElementById('student-name').value=draft.studentName||'';314  document.getElementById('student-id').value=draft.studentId||'';315  document.getElementById('group-number').value=draft.groupNumber||'';316  document.getElementById('lecturer-feedback').value=draft.feedback||'';317  document.getElementById('group-field-wrap').hidden=assignment.type!=='group';318  document.getElementById('group-import-open').hidden=assignment.type!=='group';319  document.getElementById('assignment-meta').innerHTML=`<span class="meta-chip ${assignment.type==='group'?'amber':'green'}">${assignment.type==='group'?'Group':'Individual'}</span><span class="meta-chip">Weight ${round2(assignment.weight)}%</span><span class="meta-chip green">Rubric 100%</span>`;320  const container=document.getElementById('sliders-container');container.innerHTML='';321  assignment.criteria.forEach((c,i)=>{322    const value=Math.min(num(c.max),Math.max(0,num(draft.scores[c.id],0)));323    draft.scores[c.id]=value;324    const row=document.createElement('div');row.className='grade-row';325    row.innerHTML=`<div class="grade-labels"><label>${i+1}. ${esc(c.name)}</label><span class="score-display" id="score-${esc(c.id)}">${round2(value)} / ${round2(c.max)}</span></div><input class="grade-slider" type="range" min="0" max="${num(c.max)}" step="0.5" value="${value}" data-criterion-id="${esc(c.id)}">`;326    row.querySelector('input').addEventListener('input',e=>updateScore(c.id,e.target.value));327    container.appendChild(row);328  });329  calculateTotals();updateProgress();330}331function updateScore(criterionId,value){332  const draft=currentDraft();const assignment=activeAssignment();const criterion=assignment.criteria.find(c=>c.id===criterionId);if(!criterion)return;333  draft.scores[criterionId]=Math.min(num(criterion.max),Math.max(0,num(value)));334  draft.touched[criterionId]=true;335  const el=document.getElementById(`score-${criterionId}`);if(el)el.textContent=`${round2(draft.scores[criterionId])} / ${round2(criterion.max)}`;336  calculateTotals();updateProgress();saveState();337}338function captureDraftFromUI(){339  if(!state.activeBatchId||!state.activeAssignmentId)return;340  const draft=currentDraft();341  draft.studentName=document.getElementById('student-name')?.value.trim()||'';342  draft.studentId=document.getElementById('student-id')?.value.trim()||'';343  draft.groupNumber=document.getElementById('group-number')?.value.trim()||'';344  draft.feedback=document.getElementById('lecturer-feedback')?.value.trim()||'';345  draft.pageNum=pageNum;draft.pdfScale=pdfScale;346  saveState();347}348function calculateTotals(){349  const a=activeAssignment();const d=currentDraft();if(!a)return {raw:0,contribution:0};350  const raw=round2(a.criteria.reduce((s,c)=>s+Math.min(num(c.max),Math.max(0,num(d.scores[c.id],0))),0));351  const contribution=round2(raw*num(a.weight)/100);352  document.getElementById('rubric-score').textContent=raw;353  document.getElementById('assignment-weight').textContent=round2(a.weight);354  document.getElementById('weighted-score').textContent=contribution.toFixed(2);355  document.getElementById('weighted-max').textContent=round2(a.weight);356  return {raw,contribution};357}358function updateProgress(){359  const a=activeAssignment();const d=currentDraft();if(!a)return;360  const touched=a.criteria.filter(c=>d.touched[c.id]).length,total=a.criteria.length;361  document.getElementById('rubric-progress-text').textContent=`${touched} / ${total} criteria touched`;362  document.getElementById('rubric-progress-bar').style.width=`${total?touched/total*100:0}%`;363}364function prepareSaveStudentRecord(){365  captureDraftFromUI();const d=currentDraft();const a=activeAssignment();366  if(!d.studentName||!d.studentId)return showToast('Student Name and Student ID are required.','error');367  if(!isDocumentLoaded&&!editingRecordId)return showToast('Upload the student assignment before saving the grade.','error');368  const untouched=a.criteria.filter(c=>!d.touched[c.id]).length;369  if(untouched && !confirm(`${untouched} rubric criterion/criteria have not been adjusted. Save anyway?`))return;370  ensureBatchReady(commitManualRecord);371}372function commitManualRecord(){373  const d=currentDraft(),a=activeAssignment();374  const normalizedId=d.studentId.trim();375  const existingStudent=state.records.find(r=>r.batchId===state.activeBatchId&&sameId(r.studentId,normalizedId));376  if(existingStudent && existingStudent.studentName.trim().toLowerCase()!==d.studentName.trim().toLowerCase()){377    if(!confirm(`Student ID ${normalizedId} already exists as "${existingStudent.studentName}". Continue and use "${d.studentName}" for this student across the batch?`))return;378    state.records.filter(r=>r.batchId===state.activeBatchId&&sameId(r.studentId,normalizedId)).forEach(r=>r.studentName=d.studentName);379  }380  const duplicate=state.records.find(r=>r.batchId===state.activeBatchId&&r.assignmentId===a.id&&sameId(r.studentId,normalizedId)&&r.id!==editingRecordId);381  if(duplicate && !confirm(`A ${a.name} grade already exists for ${duplicate.studentName} (${normalizedId}). Replace it?`))return;382  const totals=calculateTotals();383  const record={384    id:editingRecordId||duplicate?.id||uid('record'),batchId:state.activeBatchId,configId:state.activeConfigId,assignmentId:a.id,assignmentName:a.name,385    studentName:d.studentName,studentId:normalizedId,groupNumber:a.type==='group'?d.groupNumber:'',rawTotal:totals.raw,rubricMax:100,percentage:totals.raw,386    contribution:totals.contribution,assignmentWeight:num(a.weight),scoresById:clone(d.scores),feedback:d.feedback||'',source:'manual',savedAt:nowIso(),assignmentSnapshot:clone(a)387  };388  state.records=state.records.filter(r=>r.id!==record.id && !(r.batchId===record.batchId&&r.assignmentId===record.assignmentId&&sameId(r.studentId,record.studentId)));389  state.records.push(record);editingRecordId=null;390  resetCurrentDraftAfterSave();391  saveState();renderGradingPanel();updateBatchChip();clearDocument(true);392  showToast(`Saved ${a.name} for ${record.studentName}.`,'success');393}394function resetCurrentDraftAfterSave(){const d=currentDraft();d.studentName='';d.studentId='';d.groupNumber='';d.scores={};d.touched={};d.feedback='';}395 396// ---------- Batch setup ----------397function ensureBatchReady(action){398  if(activeBatch()?.initialized){action();return}399  pendingBatchAction=action;pendingBatchMode='first-save';document.getElementById('first-batch-name').value='';openModal('first-batch-modal');setTimeout(()=>document.getElementById('first-batch-name').focus(),30);400}401async function completeFirstBatchSetup(){402  const name=document.getElementById('first-batch-name').value.trim();if(!name)return showToast('Enter the batch / term name.','error');403  const batch=activeBatch();batch.name=name;batch.initialized=true;batch.startedAt=nowIso();saveState();updateBatchChip();closeModal('first-batch-modal');404  const action=pendingBatchAction;pendingBatchAction=null;if(action)action();405}406function openNewBatchModal(){const cfg=activeBaseConfig();document.getElementById('new-batch-name').value='';document.getElementById('new-batch-config-name').textContent=cfg?.name||'';openModal('new-batch-modal')}407function createNewBatch(){408  const name=document.getElementById('new-batch-name').value.trim();if(!name)return showToast('Enter the batch / term name.','error');409  const cfg=activeBaseConfig();let batch=activeBatch();410  if(batch && !batch.initialized && !batchRecords().length){batch.name=name;batch.initialized=true;batch.configSnapshot=clone(cfg);batch.configVersion=cfg.version;batch.createdAt=nowIso()}411  else{batch={id:uid('batch'),name,initialized:true,configId:cfg.id,configVersion:cfg.version,configSnapshot:clone(cfg),createdAt:nowIso()};state.batches[batch.id]=batch}412  state.activeBatchId=batch.id;state.activeBatchByConfig[cfg.id]=batch.id;state.activeAssignmentId=batch.configSnapshot.assignments[0]?.id||null;editingRecordId=null;413  clearDocument(false);saveState();closeModal('new-batch-modal');renderWorkspaceSelectors();renderGradingPanel();updateBatchChip();showToast(`New student batch started: ${name}`,'success');414}415 416// ---------- Viewer ----------417function setViewerMode(mode){418  const empty=document.getElementById('empty-state');419  const pdf=document.getElementById('custom-pdf-viewer');420  const conversion=document.getElementById('conversion-state');421  empty.hidden=mode!=='empty';422  pdf.hidden=mode!=='pdf';423  conversion.hidden=mode!=='conversion';424}425function updateViewerHeader(name='No assignment loaded',meta='PDF and DOCX supported · Word files are converted locally for viewing'){document.getElementById('viewer-file-name').textContent=name;document.getElementById('viewer-file-meta').textContent=meta}426async function handleDocumentUpload(file){427  const lower=file.name.toLowerCase();if(!(lower.endsWith('.pdf')||lower.endsWith('.docx')))return showToast('Only PDF and DOCX files are supported.','error');428  const d=currentDraft();const key=`document:${state.activeBatchId}:${state.activeAssignmentId}`;429  try{await idbSet(key,{blob:file,name:file.name,type:file.type||lower.split('.').pop()});d.documentKey=key;d.fileName=file.name;d.fileType=lower.endsWith('.pdf')?'pdf':'docx';saveState();loadedDocumentKey=key;await renderDocumentBlob(file,d.fileType,file.name)}catch(err){console.error(err);showToast('Could not load this document.','error');clearDocument(false)}430}431async function restoreDraftDocument(){432  const d=currentDraft();if(!d.documentKey)return;433  try{const saved=await idbGet(d.documentKey);if(!saved?.blob)return;pageNum=Math.max(1,num(d.pageNum,1));pdfScale=Math.min(3,Math.max(.6,num(d.pdfScale,1.2)));loadedDocumentKey=d.documentKey;await renderDocumentBlob(saved.blob,d.fileType||saved.type,saved.name||d.fileName||'Cached assignment')}catch(err){console.warn('Draft document restore failed',err)}434}435async function renderDocumentBlob(blob,type,name){436  isDocumentLoaded=true;437  const isPdf=type==='pdf'||String(name).toLowerCase().endsWith('.pdf');438  if(isPdf){439    updateViewerHeader(name,'Loaded locally · grading draft auto-saved');440    await loadPdfIntoViewer(blob);441    return;442  }443 444  updateViewerHeader(name,'Preparing Word document locally…');445  setViewerMode('conversion');446  document.getElementById('conversion-message').textContent='Converting DOCX to a temporary PDF preview on this device…';447  try{448    const cacheKey=loadedDocumentKey?`${loadedDocumentKey}:convertedPdf`:null;449    let converted=cacheKey?await idbGet(cacheKey).catch(()=>null):null;450    let pdfBlob=converted?.blob;451    if(!pdfBlob){452      pdfBlob=await convertDocxToPdfBlob(blob);453      if(cacheKey)await idbSet(cacheKey,{blob:pdfBlob,name:`${name}.pdf`,createdAt:nowIso()}).catch(()=>{});454    }455    updateViewerHeader(name,'Word document · converted locally to PDF for consistent viewing');456    await loadPdfIntoViewer(pdfBlob);457  }catch(err){458    console.error('DOCX conversion failed',err);459    const host=document.getElementById('docx-conversion-content');if(host)host.innerHTML='';460    setViewerMode('empty');461    updateViewerHeader();462    isDocumentLoaded=false;463    throw new Error(`Word document could not be prepared: ${err.message||err}`);464  }465}466 467async function loadPdfIntoViewer(blob){468  setViewerMode('pdf');469  pdfDoc=null;470  const buffer=await blob.arrayBuffer();471  pdfDoc=await pdfjsLib.getDocument({data:new Uint8Array(buffer)}).promise;472  document.getElementById('page_count').textContent=pdfDoc.numPages;473  pageNum=Math.min(Math.max(1,pageNum),pdfDoc.numPages);474  await renderPage(pageNum);475}476 477async function convertDocxToPdfBlob(blob){478  if(!window.docx?.renderAsync)throw new Error('Word rendering library did not load');479  if(!window.html2canvas)throw new Error('Document conversion library did not load');480  const JsPDF=window.jspdf?.jsPDF;481  if(!JsPDF)throw new Error('PDF conversion library did not load');482 483  const host=document.getElementById('docx-conversion-content');484  host.innerHTML='';485  const buffer=await blob.arrayBuffer();486  await docx.renderAsync(buffer,host,null,{487    className:'docx',488    inWrapper:true,489    ignoreWidth:false,490    ignoreHeight:false,491    ignoreFonts:false,492    breakPages:true,493    useBase64URL:true,494    renderHeaders:true,495    renderFooters:true,496    renderFootnotes:true,497    renderEndnotes:true498  });499 500  if(document.fonts?.ready)await document.fonts.ready;501  await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));502  const pages=[...host.querySelectorAll('section.docx')];503  if(!pages.length)throw new Error('No printable pages were produced from the Word document');504 505  let pdf=null;506  const captureScale=pages.length>25?1.25:pages.length>12?1.5:Math.min(2,Math.max(1.5,window.devicePixelRatio||1));507  for(let i=0;i<pages.length;i++){508    const page=pages[i];509    document.getElementById('conversion-message').textContent=`Converting Word page ${i+1} of ${pages.length}…`;510    const rect=page.getBoundingClientRect();511    if(rect.width<10||rect.height<10)throw new Error(`Word page ${i+1} could not be measured`);512    const snapshot=await html2canvas(page,{513      scale:captureScale,514      backgroundColor:'#ffffff',515      useCORS:true,516      allowTaint:false,517      logging:false,518      imageTimeout:12000,519      scrollX:0,520      scrollY:0521    });522    const widthPt=rect.width*72/96;523    const heightPt=rect.height*72/96;524    const orientation=widthPt>heightPt?'landscape':'portrait';525    if(!pdf)pdf=new JsPDF({orientation,unit:'pt',format:[widthPt,heightPt],compress:true});526    else pdf.addPage([widthPt,heightPt],orientation);527    pdf.addImage(snapshot,'JPEG',0,0,widthPt,heightPt,undefined,'FAST');528    snapshot.width=1;snapshot.height=1;529    await new Promise(resolve=>setTimeout(resolve,0));530  }531  host.innerHTML='';532  return pdf.output('blob');533}534 535async function clearDocument(removeCached){536  isDocumentLoaded=false;pdfDoc=null;pageNum=1;pageRendering=false;pageNumPending=null;pdfScrollAfterRender=null;setViewerMode('empty');updateViewerHeader();537  const conversionHost=document.getElementById('docx-conversion-content');if(conversionHost)conversionHost.innerHTML='';if(ctx)ctx.clearRect(0,0,canvas.width,canvas.height);538  const d=currentDraft();539  if(removeCached && d.documentKey){const key=d.documentKey;d.documentKey=null;d.fileName='';d.fileType='';loadedDocumentKey=null;try{await idbDelete(key);await idbDelete(`${key}:convertedPdf`)}catch{}saveState()}540}541function renderPage(n){542  if(!pdfDoc)return Promise.resolve();pageRendering=true;543  return pdfDoc.getPage(n).then(page=>{544    const ratio=window.devicePixelRatio||1;const cssVp=page.getViewport({scale:pdfScale});const renderVp=page.getViewport({scale:pdfScale*ratio});545    canvas.width=Math.floor(renderVp.width);canvas.height=Math.floor(renderVp.height);canvas.style.width=`${Math.floor(cssVp.width)}px`;canvas.style.height=`${Math.floor(cssVp.height)}px`;546    return page.render({canvasContext:ctx,viewport:renderVp}).promise;547  }).then(()=>{548    pageRendering=false;document.getElementById('page_num').textContent=n;document.getElementById('zoom_val').textContent=Math.round(pdfScale*100);549    const d=currentDraft();d.pageNum=n;d.pdfScale=pdfScale;saveState();550    if(pdfScrollAfterRender==='top')canvasWrapper.scrollTop=0;else if(pdfScrollAfterRender==='bottom')canvasWrapper.scrollTop=canvasWrapper.scrollHeight;pdfScrollAfterRender=null;551    if(pageNumPending!==null){const pending=pageNumPending;pageNumPending=null;return renderPage(pending)}552  }).catch(err=>{pageRendering=false;console.error(err);showToast('PDF page could not be rendered.','error')});553}554function queueRenderPage(n){if(pageRendering)pageNumPending=n;else renderPage(n)}555function goPdfPage(delta){if(!pdfDoc)return;const next=pageNum+delta;if(next<1||next>pdfDoc.numPages)return;pageNum=next;pdfScrollAfterRender=delta>0?'top':'bottom';queueRenderPage(pageNum)}556function zoomInPdf(){if(!pdfDoc||pdfScale>=3)return;pdfScale=round2(pdfScale+.2);pdfScrollAfterRender=null;queueRenderPage(pageNum)}557function zoomOutPdf(){if(!pdfDoc||pdfScale<=.6)return;pdfScale=round2(pdfScale-.2);pdfScrollAfterRender=null;queueRenderPage(pageNum)}558// ---------- Configuration ----------559function openConfigModal(){configEditId=state.activeConfigId;configWorkingCopy=clone(state.configurations[configEditId]);renderConfigSidebar();renderConfigEditor();openModal('config-modal')}560function renderConfigSidebar(){561  const list=document.getElementById('config-list');let configs=Object.values(state.configurations).map(clone);if(configWorkingCopy&&!state.configurations[configWorkingCopy.id])configs.push(clone(configWorkingCopy));562  list.innerHTML=configs.sort((a,b)=>a.name.localeCompare(b.name)).map(c=>`<div class="config-list-item ${c.id===configEditId?'active':''}" data-id="${esc(c.id)}"><strong>${esc(c.name)}</strong><span>${c.assignments.length} assignment(s) · ${round2(configWeightTotal(c))}% total</span></div>`).join('');563  list.querySelectorAll('.config-list-item').forEach(el=>el.onclick=()=>{const id=el.dataset.id;if(id===configWorkingCopy?.id&&!state.configurations[id])return;configEditId=id;configWorkingCopy=clone(state.configurations[id]);renderConfigSidebar();renderConfigEditor()});564}565function renderConfigEditor(){if(!configWorkingCopy)return;document.getElementById('config-name').value=configWorkingCopy.name||'';document.getElementById('config-code').value=configWorkingCopy.code||'';renderAssignmentEditorList();updateConfigSummary()}566function renderAssignmentEditorList(){567  const list=document.getElementById('assignment-editor-list');list.innerHTML='';568  configWorkingCopy.assignments.forEach(a=>{569    const card=document.createElement('div');card.className='assignment-editor-card';card.dataset.assignmentId=a.id;570    card.innerHTML=`<div class="assignment-card-header"><div><label class="field-label">Assignment Name</label><input class="field-control ae-name" value="${esc(a.name)}"></div><div><label class="field-label">Type</label><select class="field-control ae-type"><option value="individual" ${a.type==='individual'?'selected':''}>Individual</option><option value="group" ${a.type==='group'?'selected':''}>Group</option></select></div><div><label class="field-label">Weight %</label><input type="number" min="0.01" max="100" step="0.5" class="field-control ae-weight" value="${num(a.weight)}"></div><button class="delete-icon-btn ae-delete" title="Delete assignment">🗑</button></div><div class="assignment-card-body"><div class="criteria-header"><span>Rubric criterion</span><span>Rubric %</span><span></span></div><div class="criteria-editor-list"></div><div class="assignment-footer-row"><button class="btn btn-outline add-criterion-btn">+ Add Criterion</button><span class="rubric-total">Rubric total: <span class="rubric-total-value"></span> / 100%</span></div></div>`;571    const criteriaList=card.querySelector('.criteria-editor-list');a.criteria.forEach(c=>criteriaList.appendChild(makeCriterionEditorRow(c)));572    card.querySelector('.ae-name').oninput=e=>a.name=e.target.value;573    card.querySelector('.ae-type').onchange=e=>a.type=e.target.value;574    card.querySelector('.ae-weight').oninput=e=>{a.weight=num(e.target.value);updateConfigSummary()};575    card.querySelector('.ae-delete').onclick=()=>{if(configWorkingCopy.assignments.length<=1)return showToast('A configuration needs at least one assignment.','error');if(confirm(`Delete "${a.name}"?`)){configWorkingCopy.assignments=configWorkingCopy.assignments.filter(x=>x.id!==a.id);renderAssignmentEditorList();updateConfigSummary()}};576    card.querySelector('.add-criterion-btn').onclick=()=>{a.criteria.push({id:uid('criterion'),name:`Criterion ${a.criteria.length+1}`,max:10});renderAssignmentEditorList();updateConfigSummary()};577    updateCardRubricTotal(card,a);list.appendChild(card);578  });579}580function makeCriterionEditorRow(c){581  const row=document.createElement('div');row.className='criterion-editor-row';row.innerHTML=`<input class="field-control criterion-name" value="${esc(c.name)}"><input type="number" min="0.01" max="100" step="0.5" class="field-control criterion-max" value="${num(c.max)}"><button class="delete-icon-btn">✕</button>`;582  row.querySelector('.criterion-name').oninput=e=>c.name=e.target.value;583  row.querySelector('.criterion-max').oninput=e=>{c.max=num(e.target.value);const card=row.closest('.assignment-editor-card'),a=configWorkingCopy.assignments.find(x=>x.id===card.dataset.assignmentId);updateCardRubricTotal(card,a);updateConfigSummary()};584  row.querySelector('button').onclick=()=>{const card=row.closest('.assignment-editor-card'),a=configWorkingCopy.assignments.find(x=>x.id===card.dataset.assignmentId);if(a.criteria.length<=1)return showToast('Each assignment needs at least one rubric criterion.','error');a.criteria=a.criteria.filter(x=>x.id!==c.id);renderAssignmentEditorList();updateConfigSummary()};return row;585}586function updateCardRubricTotal(card,a){const total=rubricMax(a),el=card.querySelector('.rubric-total-value'),wrap=card.querySelector('.rubric-total');if(el)el.textContent=total;if(wrap)wrap.classList.toggle('invalid',Math.abs(total-100)>EPS)}587function updateConfigSummary(){if(!configWorkingCopy)return;const total=configWeightTotal(configWorkingCopy),valid=Math.abs(total-100)<=EPS;const invalidRubrics=configWorkingCopy.assignments.filter(a=>Math.abs(rubricMax(a)-100)>EPS).length;document.getElementById('config-summary').innerHTML=`<span class="summary-chip ${valid?'':'warn'}">Assignment weights: ${total} / 100%</span><span class="summary-chip ${invalidRubrics?'warn':''}">${invalidRubrics?`${invalidRubrics} rubric(s) not equal to 100%`:'All rubrics = 100%'}</span>`}588function createNewConfigWorkingCopy(){configEditId=uid('config');configWorkingCopy={id:configEditId,name:'New Configuration',code:'',version:0,createdAt:nowIso(),updatedAt:nowIso(),assignments:[{id:uid('assignment'),name:'Assignment 1',type:'individual',weight:100,criteria:[{id:uid('criterion'),name:'Criterion 1',max:100}]}]};renderConfigSidebar();renderConfigEditor()}589function duplicateSelectedConfig(){if(!configWorkingCopy)return;const copy=clone(configWorkingCopy);copy.id=uid('config');copy.name=`${copy.name} - Copy`;copy.version=0;copy.createdAt=nowIso();copy.updatedAt=nowIso();copy.assignments=copy.assignments.map(a=>({...a,id:uid('assignment'),criteria:a.criteria.map(c=>({...c,id:uid('criterion')}))}));configEditId=copy.id;configWorkingCopy=copy;renderConfigSidebar();renderConfigEditor()}590function addAssignmentToWorkingCopy(){configWorkingCopy.assignments.push({id:uid('assignment'),name:`Assignment ${configWorkingCopy.assignments.length+1}`,type:'individual',weight:0,criteria:[{id:uid('criterion'),name:'Criterion 1',max:100}]});renderAssignmentEditorList();updateConfigSummary()}591function validateWorkingConfig(){592  configWorkingCopy.name=document.getElementById('config-name').value.trim();configWorkingCopy.code=document.getElementById('config-code').value.trim();593  if(!configWorkingCopy.name)return 'Configuration name is required.';594  if(!configWorkingCopy.assignments.length)return 'Add at least one assignment.';595  if(Math.abs(configWeightTotal(configWorkingCopy)-100)>EPS)return `Assignment weights must total exactly 100%. Current total: ${configWeightTotal(configWorkingCopy)}%.`;596  const names=new Set();597  for(const a of configWorkingCopy.assignments){598    if(!a.name.trim())return 'Every assignment needs a name.';const key=a.name.trim().toLowerCase();if(names.has(key))return `Duplicate assignment name: ${a.name}`;names.add(key);599    if(num(a.weight)<=0||num(a.weight)>100)return `Assignment weight for ${a.name} must be greater than 0 and no more than 100.`;600    if(!a.criteria.length)return `${a.name} needs at least one rubric criterion.`;601    if(Math.abs(rubricMax(a)-100)>EPS)return `The rubric for ${a.name} must total exactly 100%. Current total: ${rubricMax(a)}%.`;602    for(const c of a.criteria){if(!String(c.name).trim())return `A criterion in ${a.name} is missing its name.`;if(num(c.max)<=0||num(c.max)>100)return `Rubric percentage for ${c.name||'a criterion'} must be greater than 0 and no more than 100.`}603  }604  return null;605}606function saveConfigurationFromEditor(){607  const error=validateWorkingConfig();if(error)return showToast(error,'error');608  const existed=state.configurations[configEditId];configWorkingCopy.version=(existed?.version||0)+1;configWorkingCopy.createdAt=existed?.createdAt||configWorkingCopy.createdAt||nowIso();configWorkingCopy.updatedAt=nowIso();state.configurations[configEditId]=clone(configWorkingCopy);609  let protectedCount=0;610  Object.values(state.batches).filter(b=>b.configId===configEditId).forEach(b=>{const hasRecords=state.records.some(r=>r.batchId===b.id);if(hasRecords)protectedCount++;else{b.configSnapshot=clone(configWorkingCopy);b.configVersion=configWorkingCopy.version;state.drafts[b.id]={}}});611  if(!state.activeBatchByConfig[configEditId]){const b=provisionalBatch(configWorkingCopy);state.batches[b.id]=b;state.activeBatchByConfig[configEditId]=b.id}612  if(state.activeConfigId===configEditId){const b=ensureBatchForConfig(configEditId);state.activeBatchId=b.id;if(!b.configSnapshot.assignments.some(a=>a.id===state.activeAssignmentId))state.activeAssignmentId=b.configSnapshot.assignments[0]?.id||null}613  saveState();renderConfigSidebar();renderConfigEditor();renderWorkspaceSelectors();renderGradingPanel();614  showToast(protectedCount?`Configuration saved. ${protectedCount} batch(es) already containing grades keep their existing rubric; changes apply to a new batch.`:'Configuration saved locally.','success');615}616function deleteSelectedConfig(){617  if(!configEditId)return;if(!state.configurations[configEditId]){configEditId=state.activeConfigId;configWorkingCopy=clone(state.configurations[configEditId]);renderConfigSidebar();renderConfigEditor();return}618  if(Object.keys(state.configurations).length<=1)return showToast('At least one configuration must remain.','error');619  const linked=Object.values(state.batches).filter(b=>b.configId===configEditId);if(linked.some(b=>state.records.some(r=>r.batchId===b.id)))return showToast('This configuration has saved student grades and cannot be deleted.','error');620  if(!confirm(`Delete configuration "${state.configurations[configEditId].name}"?`))return;linked.forEach(b=>{delete state.batches[b.id];delete state.drafts[b.id]});delete state.activeBatchByConfig[configEditId];delete state.configurations[configEditId];621  if(state.activeConfigId===configEditId){state.activeConfigId=Object.keys(state.configurations)[0];const b=ensureBatchForConfig(state.activeConfigId);state.activeBatchId=b.id;state.activeAssignmentId=b.configSnapshot.assignments[0]?.id||null}622  configEditId=state.activeConfigId;configWorkingCopy=clone(state.configurations[configEditId]);saveState();renderConfigSidebar();renderConfigEditor();renderWorkspaceSelectors();renderGradingPanel();623}624 625// ---------- Group import ----------626function resetGroupImportResult(){const el=document.getElementById('group-import-result');el.hidden=true;el.className='import-result';el.textContent='';document.getElementById('group-paste-area').value='';document.getElementById('group-file-input').value=''}627function downloadGroupTemplate(){628  const a=activeAssignment();if(!a||a.type!=='group')return;629  const rows=[['Student Name','Student ID','Grade','Group #'],['Example Student','S001',80,'G01']];const ws=XLSX.utils.aoa_to_sheet(rows);ws['!cols']=[{wch:25},{wch:18},{wch:12},{wch:14}];630  const info=XLSX.utils.aoa_to_sheet([['Group Grade Import Instructions'],['Assignment',a.name],['Assignment weight',`${a.weight}%`],['Grade scale','0-100'],[],['Required columns','Student Name | Student ID | Grade | Group #'],['Rule','Student ID and Grade cannot be blank.'],['Rule','Each row creates/updates one student record for this group assignment.'],['Rule','Final contribution = Grade × Assignment Weight / 100.']]);631  const wb=XLSX.utils.book_new();XLSX.utils.book_append_sheet(wb,ws,'Group Grades');XLSX.utils.book_append_sheet(wb,info,'Instructions');XLSX.writeFile(wb,`Group_Grade_Template_${safeFilename(a.name)}.xlsx`);632}633async function handleGroupFile(e){const file=e.target.files[0];e.target.value='';if(!file)return;try{const data=await file.arrayBuffer();const wb=XLSX.read(data,{type:'array'});const ws=wb.Sheets[wb.SheetNames[0]];processGroupRows(XLSX.utils.sheet_to_json(ws,{defval:''}))}catch(err){showGroupImportResult(`Could not read file: ${err.message}`,false)}}634function importPastedGroupRows(){const text=document.getElementById('group-paste-area').value.trim();if(!text)return showGroupImportResult('Paste spreadsheet rows first.',false);try{const lines=text.split(/\r?\n/).filter(Boolean).map(line=>line.split('\t'));if(lines.length<2)return showGroupImportResult('Include a header row and at least one student row.',false);const headers=lines[0].map(h=>h.trim());const rows=lines.slice(1).map(vals=>Object.fromEntries(headers.map((h,i)=>[h,vals[i]??''])));processGroupRows(rows)}catch(err){showGroupImportResult(`Could not parse pasted rows: ${err.message}`,false)}}635function normalizeRowKeys(row){const out={};Object.entries(row).forEach(([k,v])=>out[String(k).trim().toLowerCase().replace(/[._-]+/g,' ').replace(/\s+/g,' ')]=v);return out}636function getRowValue(r,aliases){for(const a of aliases)if(Object.prototype.hasOwnProperty.call(r,a))return r[a];return ''}637function processGroupRows(rawRows){638  const a=activeAssignment();if(!a||a.type!=='group')return showGroupImportResult('Select a group assignment first.',false);639  const rows=[],errors=[],seen=new Set();640  rawRows.forEach((raw,i)=>{const r=normalizeRowKeys(raw),rowNo=i+2;const studentName=String(getRowValue(r,['student name','name'])).trim();const studentId=String(getRowValue(r,['student id','studentid','id'])).trim();const gradeRaw=getRowValue(r,['grade','score','mark','marks']);const grade=Number(gradeRaw);const groupNumber=String(getRowValue(r,['group #','group','group number','group no','group no.'])).trim();641    if(!studentName)errors.push(`Row ${rowNo}: Student Name is blank.`);if(!studentId)errors.push(`Row ${rowNo}: Student ID is blank.`);if(String(gradeRaw).trim()===''||!Number.isFinite(grade))errors.push(`Row ${rowNo}: Grade is blank or not numeric.`);else if(grade<0||grade>100)errors.push(`Row ${rowNo}: Grade must be between 0 and 100.`);if(!groupNumber)errors.push(`Row ${rowNo}: Group # is blank.`);const key=studentId.toLowerCase();if(studentId&&seen.has(key))errors.push(`Row ${rowNo}: duplicate Student ID ${studentId}.`);seen.add(key);rows.push({studentName,studentId,grade,groupNumber})});642  if(!rows.length)errors.push('No student rows were found.');if(errors.length)return showGroupImportResult(errors.slice(0,12).join('\n')+(errors.length>12?`\n...and ${errors.length-12} more error(s).`:''),false);643  const groups={};rows.forEach(r=>(groups[r.groupNumber]||=[]).push(r.grade));const inconsistent=Object.entries(groups).filter(([,grades])=>new Set(grades.map(g=>round2(g))).size>1).map(([g])=>g);if(inconsistent.length&&!confirm(`Different grades were found within group(s): ${inconsistent.join(', ')}. Continue?`))return;644  const replacements=rows.filter(row=>state.records.some(r=>r.batchId===state.activeBatchId&&r.assignmentId===a.id&&sameId(r.studentId,row.studentId))).length;if(replacements&&!confirm(`${replacements} existing student grade(s) for this assignment will be replaced. Continue?`))return;645  pendingGroupRows=rows;ensureBatchReady(()=>commitGroupRows(pendingGroupRows));646}647function commitGroupRows(rows){648  const a=activeAssignment();let nameReuseCount=0;649  rows.forEach(row=>{const existing=state.records.find(r=>r.batchId===state.activeBatchId&&sameId(r.studentId,row.studentId));let studentName=row.studentName;if(existing&&existing.studentName.trim().toLowerCase()!==row.studentName.trim().toLowerCase()){studentName=existing.studentName;nameReuseCount++}650    state.records=state.records.filter(r=>!(r.batchId===state.activeBatchId&&r.assignmentId===a.id&&sameId(r.studentId,row.studentId)));651    state.records.push({id:uid('record'),batchId:state.activeBatchId,configId:state.activeConfigId,assignmentId:a.id,assignmentName:a.name,studentName,studentId:row.studentId,groupNumber:row.groupNumber,rawTotal:round2(row.grade),rubricMax:100,percentage:round2(row.grade),contribution:round2(row.grade*num(a.weight)/100),assignmentWeight:num(a.weight),scoresById:null,feedback:'',source:'group-import',savedAt:nowIso(),assignmentSnapshot:clone(a)});652  });653  saveState();updateBatchChip();showGroupImportResult(`${rows.length} student group grade(s) imported successfully.${nameReuseCount?` ${nameReuseCount} existing Student ID name(s) were kept for consistency.`:''}`,true);pendingGroupRows=null;showToast(`${rows.length} group grade records saved.`,'success');654}655function showGroupImportResult(message,success){const el=document.getElementById('group-import-result');el.hidden=false;el.className=`import-result ${success?'success':'error'}`;el.textContent=message}656 657// ---------- Gradebook ----------658function buildStudentSummaries(){659  const cfg=batchConfig(),map=new Map();660  batchRecords().forEach(r=>{const key=String(r.studentId).trim().toLowerCase();if(!map.has(key))map.set(key,{studentId:r.studentId,studentName:r.studentName,records:{},groups:new Set()});const s=map.get(key);s.studentName=r.studentName||s.studentName;s.records[r.assignmentId]=r;if(r.groupNumber)s.groups.add(r.groupNumber)});661  return [...map.values()].map(s=>{const finalScore=round2(cfg.assignments.reduce((sum,a)=>sum+num(s.records[a.id]?.contribution,0),0));const completed=cfg.assignments.filter(a=>s.records[a.id]).length;return {...s,groups:[...s.groups],finalScore,completed,totalAssignments:cfg.assignments.length,complete:completed===cfg.assignments.length}});662}663function openGradebook(){renderGradebook();openModal('gradebook-modal')}664function renderGradebook(){665  const cfg=batchConfig(),batch=activeBatch();document.getElementById('gradebook-subtitle').textContent=`${cfg?.name||''}${batch?.name?` · ${batch.name}`:' · Batch not yet named'}`;666  let students=buildStudentSummaries();const q=document.getElementById('gradebook-search').value.trim().toLowerCase();if(q)students=students.filter(s=>s.studentName.toLowerCase().includes(q)||s.studentId.toLowerCase().includes(q));const sort=document.getElementById('gradebook-sort').value;students.sort((a,b)=>sort==='name'?a.studentName.localeCompare(b.studentName):sort==='score-desc'?b.finalScore-a.finalScore:sort==='score-asc'?a.finalScore-b.finalScore:a.studentId.localeCompare(b.studentId,undefined,{numeric:true}));667  const table=document.getElementById('gradebook-table');const head=`<thead><tr><th>Student Name</th><th>Student ID</th><th>Group(s)</th>${cfg.assignments.map(a=>`<th class="score-cell">${esc(a.name)}<br><small>${round2(a.weight)}%</small></th>`).join('')}<th class="score-cell">Final / 100</th><th>Status</th><th></th></tr></thead>`;668  const body=students.length?`<tbody>${students.map(s=>`<tr><td>${esc(s.studentName)}</td><td>${esc(s.studentId)}</td><td>${esc(s.groups.join(', ')||'—')}</td>${cfg.assignments.map(a=>{const r=s.records[a.id];return `<td class="score-cell">${r?`${r.rawTotal.toFixed(1)}/100<br><small>→ ${r.contribution.toFixed(2)}/${round2(a.weight)}</small>`:'—'}</td>`}).join('')}<td class="score-cell"><strong>${s.finalScore.toFixed(2)}</strong></td><td><span class="status-pill ${s.complete?'status-complete':'status-partial'}">${s.complete?'Complete':`${s.completed}/${s.totalAssignments}`}</span></td><td><button class="mini-link" data-view-student="${esc(s.studentId)}">View</button></td></tr>`).join('')}</tbody>`:`<tbody><tr><td colspan="${cfg.assignments.length+7}" style="text-align:center;padding:30px;color:#64748b">No student grades in this batch yet.</td></tr></tbody>`;table.innerHTML=head+body;table.querySelectorAll('[data-view-student]').forEach(btn=>btn.onclick=()=>openStudentReport(btn.dataset.viewStudent));669}670function openStudentReport(studentId){671  const cfg=batchConfig(),s=buildStudentSummaries().find(x=>sameId(x.studentId,studentId));if(!s)return;document.getElementById('student-report-title').textContent=`${s.studentName} (${s.studentId})`;document.getElementById('student-report-subtitle').textContent=`${cfg.name} · ${activeBatch()?.name||'Current batch'}`;672  let html=`<div class="student-report-summary"><div class="report-stat"><span>Final Score</span><strong>${s.finalScore.toFixed(2)} / 100</strong></div><div class="report-stat"><span>Assignments Complete</span><strong>${s.completed} / ${s.totalAssignments}</strong></div><div class="report-stat"><span>Status</span><strong>${s.complete?'Complete':'Incomplete'}</strong></div><div class="report-stat"><span>Group(s)</span><strong>${esc(s.groups.join(', ')||'—')}</strong></div></div>`;673  cfg.assignments.forEach(a=>{const r=s.records[a.id];html+=`<div class="report-assignment-card"><div class="report-assignment-top"><h4>${esc(a.name)}</h4><strong>${r?`${r.rawTotal.toFixed(1)}/100 → ${r.contribution.toFixed(2)}/${round2(a.weight)}`:'Not graded'}</strong></div>${r?.groupNumber?`<p><strong>Group:</strong> ${esc(r.groupNumber)}</p>`:''}${r?.feedback?`<p><strong>Feedback:</strong> ${esc(r.feedback)}</p>`:''}${r?`<div class="record-actions">${r.source==='manual'?`<button class="btn btn-outline btn-sm" data-edit-record="${r.id}">Load / Edit</button>`:''}<button class="btn btn-danger-soft btn-sm" data-delete-record="${r.id}">Delete Record</button></div>`:''}</div>`});674  const body=document.getElementById('student-report-body');body.innerHTML=html;body.querySelectorAll('[data-delete-record]').forEach(btn=>btn.onclick=()=>deleteRecord(btn.dataset.deleteRecord,studentId));body.querySelectorAll('[data-edit-record]').forEach(btn=>btn.onclick=()=>loadRecordForEdit(btn.dataset.editRecord));openModal('student-report-modal');675}676function deleteRecord(recordId,studentId){const r=state.records.find(x=>x.id===recordId);if(!r)return;if(!confirm(`Delete ${r.assignmentName} grade for ${r.studentName}?`))return;state.records=state.records.filter(x=>x.id!==recordId);saveState();openStudentReport(studentId);renderGradebook();showToast('Grade record deleted.','success')}677function loadRecordForEdit(recordId){678  const r=state.records.find(x=>x.id===recordId);if(!r||r.source!=='manual')return;state.activeConfigId=r.configId;state.activeBatchId=r.batchId;state.activeBatchByConfig[r.configId]=r.batchId;state.activeAssignmentId=r.assignmentId;editingRecordId=r.id;const d=currentDraft();d.studentName=r.studentName;d.studentId=r.studentId;d.groupNumber=r.groupNumber||'';d.feedback=r.feedback||'';d.scores=clone(r.scoresById||{});d.touched={};activeAssignment().criteria.forEach(c=>d.touched[c.id]=true);saveState();closeModal('student-report-modal');closeModal('gradebook-modal');clearDocument(false);renderWorkspaceSelectors();renderGradingPanel();updateBatchChip();showToast('Saved grade loaded for editing. Uploading the paper again is optional for this edit.','success')679}680 681function exportGradebookXlsx(){682  const cfg=batchConfig(),batch=activeBatch(),students=buildStudentSummaries().sort((a,b)=>a.studentId.localeCompare(b.studentId,undefined,{numeric:true}));if(!students.length)return showToast('No grade records exist in this batch yet.','error');683  const headers=['Student Name','Student ID','Group(s)'];cfg.assignments.forEach(a=>{headers.push(`${a.name} Score /100`);headers.push(`${a.name} Weight %`);headers.push(`${a.name} Contribution`)});headers.push('Final Score /100','Completion');684  const rows=[headers];students.forEach(s=>{const row=[s.studentName,s.studentId,s.groups.join(', ')];cfg.assignments.forEach(a=>{const r=s.records[a.id];row.push(r?.rawTotal??'');row.push(a.weight);row.push(r?.contribution??'')});row.push(s.finalScore);row.push(`${s.completed}/${s.totalAssignments}`);rows.push(row)});685  const wb=XLSX.utils.book_new();const used=new Set();const sheetName=base=>{let n=safeSheetName(base),c=n,i=2;while(used.has(c.toLowerCase())){const suff=` ${i++}`;c=n.slice(0,31-suff.length)+suff}used.add(c.toLowerCase());return c};686  const ws=XLSX.utils.aoa_to_sheet(rows);ws['!cols']=headers.map((h,i)=>({wch:i<3?22:Math.min(30,Math.max(12,h.length+2))}));ws['!autofilter']={ref:`A1:${XLSX.utils.encode_col(headers.length-1)}${rows.length}`};XLSX.utils.book_append_sheet(wb,ws,sheetName('Final Scores'));687  cfg.assignments.forEach(a=>{const ar=[['Student Name','Student ID','Group #','Score /100','Assignment Weight %','Contribution','Source','Feedback','Saved At']];batchRecords().filter(r=>r.assignmentId===a.id).sort((x,y)=>x.studentId.localeCompare(y.studentId,undefined,{numeric:true})).forEach(r=>ar.push([r.studentName,r.studentId,r.groupNumber,r.rawTotal,a.weight,r.contribution,r.source,r.feedback,r.savedAt]));const aws=XLSX.utils.aoa_to_sheet(ar);aws['!cols']=[{wch:24},{wch:18},{wch:12},{wch:12},{wch:20},{wch:16},{wch:14},{wch:45},{wch:24}];XLSX.utils.book_append_sheet(wb,aws,sheetName(a.name))});688  const cr=[['Configuration',cfg.name],['Subject / Code',cfg.code||''],['Batch / Term',batch?.name||''],['Configuration Total','100%'],[],['Assignment','Type','Weight %','Rubric Total','Criteria']];cfg.assignments.forEach(a=>cr.push([a.name,a.type,a.weight,'100%',a.criteria.map(c=>`${c.name} (${c.max}%)`).join('; ')]));const cws=XLSX.utils.aoa_to_sheet(cr);cws['!cols']=[{wch:36},{wch:16},{wch:14},{wch:14},{wch:90}];XLSX.utils.book_append_sheet(wb,cws,sheetName('Configuration'));689  XLSX.writeFile(wb,`${safeFilename(cfg.name)}_${safeFilename(batch?.name||'Current_Batch')}_Final_Grades.xlsx`);showToast('Final gradebook exported to Excel.','success');690}691function printAllStudentReports(){692  const cfg=batchConfig(),batch=activeBatch(),students=buildStudentSummaries().sort((a,b)=>a.studentId.localeCompare(b.studentId,undefined,{numeric:true}));if(!students.length)return showToast('No student records to print.','error');const w=window.open('','_blank');if(!w)return showToast('Pop-up blocked. Allow pop-ups to print reports.','error');693  const sections=students.map(s=>`<section class="student"><h2>${esc(s.studentName)} <small>${esc(s.studentId)}</small></h2><p class="meta">${esc(cfg.name)} · ${esc(batch?.name||'Current batch')}</p><table><thead><tr><th>Assignment</th><th>Score /100</th><th>Weight</th><th>Contribution</th><th>Group</th></tr></thead><tbody>${cfg.assignments.map(a=>{const r=s.records[a.id];return `<tr><td>${esc(a.name)}</td><td>${r?r.rawTotal.toFixed(1):'—'}</td><td>${round2(a.weight)}%</td><td>${r?r.contribution.toFixed(2):'—'}</td><td>${r?.groupNumber?esc(r.groupNumber):'—'}</td></tr>${r?.feedback?`<tr class="feedback"><td colspan="5"><strong>Feedback:</strong> ${esc(r.feedback)}</td></tr>`:''}`}).join('')}</tbody></table><div class="final"><strong>Final Score:</strong> ${s.finalScore.toFixed(2)} / 100 &nbsp; <strong>Completion:</strong> ${s.completed}/${s.totalAssignments}</div></section>`).join('');694  w.document.write(`<!doctype html><html><head><title>Student Grade Reports</title><style>body{font-family:Arial,sans-serif;color:#243244;margin:28px}.student{page-break-after:always}.student:last-child{page-break-after:auto}h1,h2{color:#064e3b}h2{margin-bottom:3px}h2 small{font-size:14px;color:#64748b}.meta{color:#64748b;margin-top:0}table{width:100%;border-collapse:collapse;margin-top:16px;font-size:12px}th,td{border:1px solid #cbd5e1;padding:8px;text-align:left}th{background:#f1f5f9}.feedback td{background:#fafafa}.final{margin-top:14px;padding:12px;background:#ecfdf5;border:1px solid #a7f3d0}@media print{body{margin:12mm}}</style></head><body><h1>Academic Evaluator Pro · Student Grade Reports</h1>${sections}</body></html>`);w.document.close();setTimeout(()=>w.print(),250);695}696 697// ---------- Backup ----------698function buildBackupPayload(){return {type:'AcademicEvaluatorProSessionBackup',appVersion:APP_VERSION,exportedAt:nowIso(),state}}699function downloadJson(filename,payload){const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=filename;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url)}700async function updateBackupUI(){701  const card=document.getElementById('backup-status-card');702  card.innerHTML=`<strong>Browser auto-save:</strong> Active<br><strong>Separate safety copy:</strong> Use “Save Session Backup File” below.<br><span class="microcopy">The backup is downloaded through the browser, which avoids embedded-frame folder-picker restrictions.</span>`;703}704async function restoreBackupFromFile(e){const file=e.target.files[0];e.target.value='';if(!file)return;try{const parsed=JSON.parse(await file.text()),incoming=parsed.state||parsed;if(!incoming.configurations||!incoming.batches||!Array.isArray(incoming.records))throw new Error('Not a valid session backup');if(!confirm('Restore this session backup? Current browser data will be replaced.'))return;state=incoming;ensureStateIntegrity();saveState();clearDocument(false);renderWorkspaceSelectors();renderGradingPanel();updateBatchChip();closeModal('backup-modal');await restoreDraftDocument();showToast('Session backup restored.','success')}catch(err){showToast(`Restore failed: ${err.message}`,'error')}}705 706// ---------- UI helpers ----------707function setAutosaveStatus(text){const el=document.getElementById('autosave-pill');if(!el)return;el.textContent=text;clearTimeout(el._timer);el._timer=setTimeout(()=>el.textContent='✓ Auto-save active',1400)}708function openModal(id){document.getElementById(id).hidden=false}709function closeModal(id){document.getElementById(id).hidden=true}710function showToast(message,type=''){const t=document.getElementById('toast');t.textContent=message;t.className=`toast ${type}`;t.hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.hidden=true,3800)}711function safeFilename(name){return String(name||'Academic_Evaluator').replace(/[\\/:*?"<>|]+/g,'_').replace(/\s+/g,'_').slice(0,80)}712function safeSheetName(name){return String(name||'Assignment').replace(/[\\/?*\[\]:]/g,' ').slice(0,31)||'Assignment'}713 714window.addEventListener('beforeunload',()=>{try{captureDraftFromUI();localStorage.setItem(STORAGE_KEY,JSON.stringify(state))}catch{}});715