/** * @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
Use student IDs to combine two tabs. Row 1 must contain unique column headers. The merged result takes your roster\u2019s name. The original roster becomes a hidden, dated backup. You can choose whether to keep the incoming tab.
Blank incoming values never erase existing values. The master\u2019s formatting and unchanged cells are preserved. Incoming values are added as text. Students only in the main roster remain in the result.
\nDeletion removes the entire selected incoming tab, including columns you did not merge. The original roster is kept as a hidden backup.
\nYour updated roster is ready.
To view a backup later, choose View \u2192 Hidden sheets.
Oldest first. Only hidden tabs named using the merger\u2019s dated backup pattern appear here. Deleting removes the entire backup tab.
\n| Select | Backup tab | Merge date/time |
|---|
These backup tabs will be deleted. Your current roster tabs will be kept.