// Metadata only: current Drive scope is sufficient. No file contents or sharing writes. const INTABL_PHOTO_HEADERS = ['Filename','File ID','SKU','URL','Resource key','Status']; const INTABL_PHOTO_MARKER = 'Intabl images v1'; const INTABL_PHOTO_NOTE = 'Intabl photo v1: '; const INTABL_PHOTO_STAGE = '_intabl_images_scan'; function intablPhotoFolder_(input) { const value=String(input || '').trim(); let id=value,key=''; if(/^https:\/\//i.test(value)) { const match=value.match(/^https:\/\/drive\.google\.com\/(?:drive\/(?:u\/\d+\/)?folders\/([\w-]+)|open\?id=([\w-]+))(?:[?&#/].*)?$/i); if(!match)throw new Error('Paste a Google Drive folder link.'); id=match[1] || match[2]; const keyMatch=value.match(/[?&]resourcekey=([\w-]+)/i);key=keyMatch?keyMatch[1]:''; } if(!/^[\w-]{10,200}$/.test(id))throw new Error('Invalid Google Drive folder ID.'); return {id:id,key:key}; } function intablPhotoApi_(path,params,folder) { const query=Object.keys(params).map(function(k){return encodeURIComponent(k)+'='+encodeURIComponent(params[k]);}).join('&'); const headers={Authorization:'Bearer '+ScriptApp.getOAuthToken()}; if(folder && folder.key)headers['X-Goog-Drive-Resource-Keys']=folder.id+'/'+folder.key; const response=UrlFetchApp.fetch('https://www.googleapis.com/drive/v3/'+path+'?'+query,{ method:'get',headers:headers,muteHttpExceptions:true,followRedirects:false }); const status=response.getResponseCode(); if(status<200 || status>=300)throw new Error('Google Drive: HTTP '+status+'. Check folder access. For a temporary error, try the update again.'); let data;try{data=JSON.parse(response.getContentText());}catch(_){throw new Error('Unexpected Google Drive response. Try the update again.');} return data; } function intablPhotoPublic_(file) { return (file.permissions || []).some(function(p){return p.type==='anyone' && ['reader','writer'].indexOf(p.role)>=0 && !p.deleted;}); } function intablPhotoCheckFolder_(folder) { const file=intablPhotoApi_('files/'+encodeURIComponent(folder.id),{ fields:'id,name,mimeType,trashed,permissions(type,role,deleted)',supportsAllDrives:'true' },folder); if(file.trashed || file.mimeType!=='application/vnd.google-apps.folder')throw new Error('A valid Google Drive folder is required.'); if(!intablPhotoPublic_(file))throw new Error('Set folder access to Anyone with the link → Viewer. The script does not change sharing automatically.'); return file; } function configureIntablPhotos() { SpreadsheetApp.getUi().showModalDialog(HtmlService.createHtmlOutput(intablPhotoDialog_()).setWidth(560).setHeight(660),'Store photo folder'); } function intablPhotoFolderUrl_(folder) { return 'https://drive.google.com/drive/folders/'+encodeURIComponent(folder.id)+(folder.key?'?resourcekey='+encodeURIComponent(folder.key):''); } function getIntablPhotoFolderStatus() { const stored=intablProperties_().getProperty('INTABL_PHOTO_FOLDER'); if(!stored)return {connected:false,url:'',message:'No folder connected yet.'}; let url=''; try { const data=JSON.parse(stored),folder=intablPhotoFolder_(intablPhotoFolderUrl_(data)); url=intablPhotoFolderUrl_(folder); const file=intablPhotoCheckFolder_(folder); return {connected:true,name:file.name,url:url}; }catch(error){return {connected:false,url:url,message:'Folder saved, but access could not be verified. '+error.message};} } function saveIntablPhotoFolder(input,confirmOverwrite) { const folder=intablPhotoFolder_(input),file=intablPhotoCheckFolder_(folder); const ss=SpreadsheetApp.getActiveSpreadsheet(); const lock=LockService.getScriptLock();lock.waitLock(5000); try { const existing=ss.getSheetByName('images'); if(existing && existing.getRange('A1').getNote()!==INTABL_PHOTO_MARKER && existing.getLastRow()>0 && confirmOverwrite!==true) { return {needsConfirmation:true,message:'After a full scan, columns A:F in images will be replaced with the photo list. Filename and file ID stay in A:B for compatibility with existing formulas.'}; } const props=intablProperties_(); props.setProperty('INTABL_PHOTO_FOLDER',JSON.stringify(folder)); props.deleteProperty('INTABL_PHOTO_RUN'); if(existing)existing.getRange('A1').setNote(INTABL_PHOTO_MARKER); }finally{lock.releaseLock();} return {connected:true,name:file.name,url:intablPhotoFolderUrl_(folder),message:'Now choose INTABL → Update product and category photos. Manual links and formulas are preserved.'}; } function intablPhotoDialog_() { return `
Checking photo folder…

How to name photos

The filename is your product SKU, not a sequential photo number.

For SKU cup-001:

Main photo: cup-001.jpg
Additional photos: cup-001__2.jpg, cup-001__3.jpg … up to cup-001__10.jpg.

Use exactly two underscores __ before each additional photo number.

Use sku, or id if there is no sku column. Keep leading zeros: for 00125, use 00125.jpg.

Category photos use their category id. Formats: JPG, JPEG, JFIF, PNG, WebP, GIF. Subfolders are not scanned.

Access: Anyone with the link → Viewer. The photo list appears in images after updating.

`; } function intablPhotoName_(name) { const match=String(name).match(/^(.+)\.(jpe?g|jfif|png|webp|gif)$/i); if(!match)return null; const suffix=match[1].match(/^(.+)__(\d+)$/); const sku=(suffix?suffix[1]:match[1]).trim(),order=suffix?Number(suffix[2]):1; if(!sku || /[\r\n]/.test(sku) || (suffix && (!/^(?:[2-9]|10)$/.test(suffix[2]))))return null; return {sku:sku,order:order}; } function intablPhotoRow_(file) { const name=String(file.name || ''),id=String(file.id || ''),key=String(file.resourceKey || ''); const match=name.match(/^(.+)\.(jpe?g|jfif|png|webp|gif)$/i); const parsed=intablPhotoName_(name),sku=parsed?parsed.sku:''; let status='Ready',url=''; if(!match || !/^image\/(jpeg|png|webp|gif)$/.test(file.mimeType || ''))status='Unsupported format'; else if(!parsed)status='Invalid filename: SKU.jpg or SKU__2…__10.jpg'; else if(!intablPhotoPublic_(file))status='Public access not confirmed'; else if(key && (!file.linkShareMetadata || file.linkShareMetadata.securityUpdateEnabled!==false))status='Resource key required: paste a verified link manually'; else if(!/^[\w-]{10,200}$/.test(id))status='Invalid file ID'; else url=intablPhotoUrl_(id); return [name,id,sku,url,key,status]; } function intablPhotoUrl_(id) { // Compatibility adapter for the existing storefront. This URL is not a Drive SLA. return 'https://lh3.googleusercontent.com/d/'+encodeURIComponent(id)+'=s800'; } function intablPhotoIndex_(rows) { const groups=new Map(),seen=new Set(); rows.forEach(function(row){ if(seen.has(row[1]))throw new Error('Google returned a duplicate file. Configure the folder again and repeat the full scan.'); seen.add(row[1]); const parsed=intablPhotoName_(row[0]); // Re-parse filenames so a resumed scan from the previous version also groups correctly. if(parsed){row[2]=parsed.sku;if(!groups.has(row[2]))groups.set(row[2],[]);groups.get(row[2]).push({row:row,order:parsed.order});} }); const index=new Map(),additional=new Map();let conflicts=0; groups.forEach(function(group,sku){ if(new Set(group.map(function(item){return item.order;})).size!==group.length){conflicts++;group.forEach(function(item){item.row[3]='';item.row[5]='Duplicate photo number: keep one file per number';});return;} const urls=group.sort(function(a,b){return a.order-b.order;}).map(function(item){return item.row[3];}).filter(Boolean); if(urls.length){index.set(sku,urls[0]);additional.set(sku,urls.slice(1).join('\n'));} }); return {index:index,additional:additional,conflicts:conflicts}; } function intablPhotoPlan_(headers,values,formulas,notes,index,column) { column=column || 'image-url'; const prefix=column==='image-urls'?'Intabl photos v1: ':INTABL_PHOTO_NOTE; const markerValue=function(value){return prefix+(column==='image-urls'?JSON.stringify(value):value);}; const keys=headers.map(function(v){return String(v).trim().toLowerCase();}); ['id',column].forEach(function(k){if(keys.filter(function(v){return v===k;}).length!==1)throw new Error('The sheet must contain exactly one column named '+k+'.');}); if(keys.filter(function(v){return v==='sku';}).length>1)throw new Error('Duplicate sku column in goods.'); const idCol=keys.indexOf('id'),skuCol=column!=='image-url-category'&&keys.indexOf('sku')>=0?keys.indexOf('sku'):idCol,imageCol=keys.indexOf(column); const counts=new Map(),ids=new Map(); values.forEach(function(row){ const sku=String(row[skuCol] || '').trim(),id=String(row[idCol] || '').trim(); if(sku)counts.set(sku,(counts.get(sku)||0)+1);if(id)ids.set(id,(ids.get(id)||0)+1); }); const changes=[];let missing=0,preserved=0,duplicate=0,unchanged=0; values.forEach(function(row,i){ const id=String(row[idCol] || '').trim(),sku=String(row[skuCol] || '').trim(); if(!id)return; if(counts.get(sku)>1 || ids.get(id)>1){duplicate++;return;} const url=index.get(sku);if(!index.has(sku)){missing++;return;} const current=String(row[imageCol] || ''),note=notes[i][0] || '',formula=formulas[i][0]; const marker=note.split('\n').find(function(line){return line.indexOf(prefix)===0;}); if(formula || (current!=='' && marker!==markerValue(current))){preserved++;return;} if(current===url){unchanged++;return;} const ownNote=note.split('\n').filter(function(line){return line.indexOf(prefix)!==0;}).join('\n').trim(); changes.push({row:i+2,column:imageCol+1,before:current,note:note,url:url,nextNote:(ownNote?ownNote+'\n':'')+markerValue(url)}); }); return {changes:changes,missing:missing,preserved:preserved,duplicate:duplicate,unchanged:unchanged}; } function intablPhotoTable_(ss,name) { let sheet=ss.getSheetByName(name); if(sheet && sheet.getLastRow()>0 && sheet.getRange('A1').getNote()!==INTABL_PHOTO_MARKER)throw new Error('Sheet '+name+' already contains other data. For images, open Configure photo folder; rename the service sheet '+INTABL_PHOTO_STAGE+'.'); if(!sheet)sheet=ss.insertSheet(name); if(sheet.getMaxColumns()<6)sheet.insertColumnsAfter(sheet.getMaxColumns(),6-sheet.getMaxColumns()); sheet.getRange(1,1,1,6).setValues([INTABL_PHOTO_HEADERS]).setFontWeight('bold').setBackground('#eeeeee'); sheet.getRange('A1').setNote(INTABL_PHOTO_MARKER);sheet.setFrozenRows(1); return sheet; } function intablPhotoWriteRows_(sheet,start,rows) { if(!rows.length)return; const end=start+rows.length-1; if(end>sheet.getMaxRows())sheet.insertRowsAfter(sheet.getMaxRows(),end-sheet.getMaxRows()); // Quote leading '=' to prevent filenames from becoming spreadsheet formulas. const safe=rows.map(function(row){return row.map(function(v){return String(v).charAt(0)==='='?"'"+v:String(v);});}); sheet.getRange(start,1,rows.length,6).setNumberFormat('@').setValues(safe); } function intablPhotoApply_(goods,plan) { let updated=0,skipped=0,blocks=0;const started=Date.now(); // Only contiguous changed cells are written; manual formulas in other rows are preserved. let start=0; while(start=40 || Date.now()-started>90000)return {updated:updated,skipped:skipped,pending:true}; let end=start+1; while(endpart.sheet);intablRefreshCatalogSheets_(); let run=JSON.parse(props.getProperty('INTABL_PHOTO_RUN') || 'null'); const stage=intablPhotoTable_(ss,INTABL_PHOTO_STAGE); if(!run || run.folder!==folder.id || Date.now()-run.started>3600000) { run={folder:folder.id,started:Date.now(),count:0,page:'',done:false}; props.setProperty('INTABL_PHOTO_RUN',JSON.stringify(run)); } if(stage.getLastRow()>run.count+1)stage.getRange(run.count+2,1,stage.getLastRow()-run.count-1,6).clearContent(); const started=Date.now(); for(let pages=0;!run.done && pages<5 && Date.now()-started<150000;pages++) { const params={q:"'"+folder.id+"' in parents and trashed = false and mimeType contains 'image/'",pageSize:200, fields:'nextPageToken,incompleteSearch,files(id,name,mimeType,resourceKey,linkShareMetadata,permissions(type,role,deleted))', supportsAllDrives:'true',includeItemsFromAllDrives:'true'}; if(run.page)params.pageToken=run.page; const result=intablPhotoApi_('files',params,folder); if(result.incompleteSearch)throw new Error('Google returned an incomplete file list. Repeat the full scan.'); const files=result.files || []; if(run.count+files.length>5000)throw new Error('This folder contains more than 5000 photos. Choose a smaller folder.'); intablPhotoWriteRows_(stage,run.count+2,files.map(intablPhotoRow_)); SpreadsheetApp.flush(); run.count+=files.length;run.page=result.nextPageToken || '';run.done=!run.page; props.setProperty('INTABL_PHOTO_RUN',JSON.stringify(run)); } if(!run.done)return 'Photos scanned: '+run.count+'. Run Update product and category photos again to continue. Product links stay unchanged until the scan finishes.'; const rows=run.count?stage.getRange(2,1,run.count,6).getDisplayValues():[]; const indexed=intablPhotoIndex_(rows); const plans=goodsSheets.map(goods=>{ const height=Math.max(1,goods.getLastRow()),width=goods.getLastColumn(); const values=goods.getRange(1,1,height,width).getDisplayValues(),headers=values.shift(); const isCategory=goods.getName()==='category',imageKey=isCategory?'image-url-category':'image-url'; let imageCol=headers.map(function(v){return String(v).trim().toLowerCase();}).indexOf(imageKey)+1; if(!imageCol && isCategory){ if(width>=26)throw new Error('category: add image-url-category within A:Z.'); imageCol=width+1; if(goods.getMaxColumns()=26)throw new Error('The gallery needs an image-urls column within A:Z. Make room or rename an empty column.'); galleryCol=width+1; if(goods.getMaxColumns()rows.length+1)images.getRange(rows.length+2,1,images.getLastRow()-rows.length-1,6).clearContent(); images.setColumnWidth(1,210);images.setColumnWidth(2,260);images.setColumnWidth(3,130);images.setColumnWidth(4,330);images.setColumnWidth(5,180);images.setColumnWidth(6,360); const applied={updated:0,skipped:0,pending:false}; for(const part of plans){const result=intablPhotoApply_(part.goods,part.plan);applied.updated+=result.updated;applied.skipped+=result.skipped;applied.pending=applied.pending||result.pending;} if(applied.pending){SpreadsheetApp.flush();return 'Photos updated in this batch: '+applied.updated+'. Run Update product and category photos again to finish. Updated photos are saved.';} SpreadsheetApp.flush();props.deleteProperty('INTABL_PHOTO_RUN'); if(stage.getLastRow()>1)stage.getRange(2,1,stage.getLastRow()-1,6).clearContent();stage.hideSheet(); return 'Photo cells updated: '+applied.updated+'. Already up to date: '+plan.unchanged+'.\nManual links/formulas preserved: '+plan.preserved+'. Without a matching photo: '+plan.missing+'.\nConflicting photo numbers: '+indexed.conflicts+'; duplicate products: '+plan.duplicate+'. Rows edited during the update and skipped: '+applied.skipped+'.\nFiles found: '+rows.length+'. See images for skipped-file details. Main photo: image-url; additional photos: image-urls; category photo: image-url-category.\nCheck your store while signed out of Google, then run Update catalog for Google.'; }