/** * @OnlyCurrentDoc * Student Data Merger — single-file installation. * If your project already defines onOpen, remove ONLY the wrapper below * and add sdmOnOpen(); inside your existing onOpen function. */ function onOpen(e) { sdmOnOpen(); } /** Student Data Merger 1.5.0. Only the current spreadsheet is accessed. */ function sdmOnOpen() { SpreadsheetApp.getUi().createMenu('Student Data').addItem('Merge Data', 'sdmShowMerger').addSeparator().addItem('Manage Backups', 'sdmShowBackupManager').addToUi(); } function sdmShowMerger() { SpreadsheetApp.getUi().showModalDialog(sdmMergerDialog_('Merger',sdmGetMergerSheets).setWidth(1000).setHeight(730), 'Student Data Merger · 1.5.0'); } function sdmGetMergerSheets() { return SpreadsheetApp.getActive().getSheets().filter(function(s) { return !s.isSheetHidden(); }).map(function(s) { var lastColumn=s.getLastColumn(); // Only row 1 is fetched at launch, regardless of the number of students. return {id:s.getSheetId(), name:s.getName(), headers:lastColumn ? s.getRange(1,1,1,lastColumn).getDisplayValues()[0] : []}; }); } function sdmReadMergeSource_(id) { var s=SpreadsheetApp.getActive().getSheetById(Number(id)); if(!s) throw new Error('A selected tab no longer exists. Reopen the tool.'); var rows=s.getDataRange().getDisplayValues(); if(rows.length<2) throw new Error(s.getName()+': add headers in row 1 and at least one student row.'); var headers=rows[0].map(function(h){return h.trim();}); if(headers.some(function(h){return !h;}) || new Set(headers).size!==headers.length) throw new Error(s.getName()+': every column needs a unique, nonblank header.'); return {name:s.getName(), headers:headers, rows:rows.slice(1).map(function(r,i){return {values:r, row:i+2};}).filter(function(r){return r.values.some(function(v){return v!=='';});})}; } function sdmNormalizeStudentId_(v) { return String(v).trim(); } function sdmBuildMergePlan_(main, incoming, config) { var mi=Number(config.mainKey), ii=Number(config.incomingKey); if(!Number.isInteger(mi)||mi<0||mi>=main.headers.length||!Number.isInteger(ii)||ii<0||ii>=incoming.headers.length) throw new Error('Choose valid Student ID columns.'); var cols=config.columns; if(!Array.isArray(cols)||!cols.length) throw new Error('Select at least one incoming column.'); if(new Set(cols).size!==cols.length || cols.some(function(c){return !Number.isInteger(c)||c<0||c>=incoming.headers.length||c===ii;})) throw new Error('Invalid incoming column selection.'); function index(source,key) { var map=new Map(), errors=[]; source.rows.forEach(function(r){ var id=sdmNormalizeStudentId_(r.values[key]); if(!id) errors.push('blank ID at row '+r.row); else if(map.has(id)) errors.push('duplicate ID '+id+' at rows '+map.get(id).row+' and '+r.row); else map.set(id,r); }); if(errors.length) throw new Error(source.name+': '+errors.slice(0,15).join('; ')+(errors.length>15 ? '; additional issues exist.' : '')); return map; } var master=index(main,mi), extra=index(incoming,ii), headers=main.headers.slice(); var mappings=cols.map(function(c){ var target=headers.indexOf(incoming.headers[c]); // Never let an incoming field overwrite the master ID column. if(target===mi) throw new Error('Incoming column '+incoming.headers[c]+' collides with the roster ID header. Rename that incoming header first.'); if(target<0){target=headers.length;headers.push(incoming.headers[c]);} return {source:c,target:target}; }); var conflicts=[], newcomers=[], missing=[], matched=0; extra.forEach(function(r,id){ if(!master.has(id)) newcomers.push({id:id,values:r.values,row:r.row}); else { matched++; mappings.forEach(function(m){var old=master.get(id).values[m.target]||'', next=r.values[m.source]; if(old!=='' && next!=='' && old!==next) conflicts.push({key:r.row+':'+m.source,id:id,column:headers[m.target],original:old,incoming:next}); }); } }); master.forEach(function(r,id){if(!extra.has(id)) missing.push(id);}); return {headers:headers,mappings:mappings,conflicts:conflicts,newcomers:newcomers,missing:missing,matched:matched,mainKey:mi,incomingKey:ii}; } function sdmLoadMerge_(config) { // Hash only known settings in a fixed order. RPC object key order is not stable. config={mainSheet:Number(config.mainSheet),incomingSheet:Number(config.incomingSheet),mainKey:Number(config.mainKey),incomingKey:Number(config.incomingKey),columns:config.columns}; if(Number(config.mainSheet)===Number(config.incomingSheet)) throw new Error('Choose two different tabs.'); var main=sdmReadMergeSource_(config.mainSheet), incoming=sdmReadMergeSource_(config.incomingSheet); var plan=sdmBuildMergePlan_(main,incoming,config); var digest=Utilities.base64Encode(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256,JSON.stringify({main:main,incoming:incoming,config:config}))); return {main:main,incoming:incoming,plan:plan,digest:digest}; } function sdmPreviewMerge(config) { var data=sdmLoadMerge_(config), p=data.plan; return {digest:data.digest,matched:p.matched,mainCount:data.main.rows.length,missing:p.missing,conflicts:p.conflicts,newcomers:p.newcomers,incomingHeaders:data.incoming.headers}; } function sdmMergedRows_(data, choices) { var p=data.plan, byId=new Map(data.incoming.rows.map(function(r){return [sdmNormalizeStudentId_(r.values[p.incomingKey]),r];})); if(['keep','incoming','review'].indexOf(choices.conflictMode)<0 || ['add','exclude','review'].indexOf(choices.newMode)<0) throw new Error('Choose how to handle new students and conflicts.'); var validNew=new Set(p.newcomers.map(function(r){return r.id;})), selected=new Set(choices.includeIds||[]); selected.forEach(function(id){if(!validNew.has(id)) throw new Error('Invalid new student selection.');}); if(choices.conflictMode==='review') p.conflicts.forEach(function(c){if(!choices.conflicts || ['keep','incoming'].indexOf(choices.conflicts[c.key])<0) throw new Error('Resolve every conflict.');}); var result=data.main.rows.map(function(r){ var row=r.values.slice(); while(row.length