const INTABL_SCRIPT_VERSION = '1.0.1'; const INTABL_API = 'https://api.intabl.com/api/v1'; const INTABL_ADMIN = 'https://admin.intabl.com'; function onOpen() { try{intablRefreshCatalogSheets_();intablCheckIds_(false);}catch(error){console.log(error.message);} SpreadsheetApp.getUi().createMenu('βοΈINTABL') .addItem('β Connect store', 'connectIntablShop') .addItem('πͺ Open store', 'openIntablShop') .addItem('π€ Open dashboard', 'openIntablAdmin') .addItem('π Refresh product sheets', 'refreshIntablCatalogSheets') .addItem('π Update catalog for Google', 'syncIntablMarketplace') .addItem('π Check product IDs', 'checkIntablIds') .addSeparator() .addItem('π Configure photo folder', 'configureIntablPhotos') .addItem('πΌοΈ Update product and category photos', 'updateIntablPhotos') .addSeparator() .addItem('π Check catalog access', 'checkIntablCatalogue') .addItem('π‘ Check connection', 'checkIntablConnection').addToUi(); } function intablRequest_(path, method, payload, token) { const options={method:method || 'get',muteHttpExceptions:true,followRedirects:false,headers:{'X-Intabl-Script-Version':INTABL_SCRIPT_VERSION}}; if(payload!==undefined){options.contentType='application/json';options.payload=JSON.stringify(payload);} if(token){options.headers.Authorization='Bearer '+token;} const response=UrlFetchApp.fetch(INTABL_API+path,options); let result; try{result=JSON.parse(response.getContentText());}catch(_){throw new Error('Unexpected server response. HTTP '+response.getResponseCode());} if(response.getResponseCode()<200 || response.getResponseCode()>=300 || !result.success){throw new Error(result.error && (result.error.code ? 'Request failed ('+result.error.code+'). Check your connection and try again.' : 'Request failed. Try again.') || 'Connection error. HTTP '+response.getResponseCode());} return result.data; } function intablProperties_() { const props=PropertiesService.getScriptProperties(); // One-time compatibility migration for existing connected spreadsheets. const legacy='TIIBL_',current='INTABL_',stored=props.getProperties(),migrated={}; Object.keys(stored).filter(key=>key.indexOf(legacy)===0).forEach(key=>{ const next=current+key.slice(legacy.length);if(stored[next]===undefined)migrated[next]=stored[key]; }); if(Object.keys(migrated).length)props.setProperties(migrated); Object.keys(stored).filter(key=>key.indexOf(legacy)===0).forEach(key=>props.deleteProperty(key)); const id=SpreadsheetApp.getActiveSpreadsheet().getId(); if(props.getProperty('INTABL_SHEET_ID')!==id){ Object.keys(props.getProperties()).filter(function(key){return key.indexOf('INTABL_')===0;}).forEach(function(key){props.deleteProperty(key);}); props.setProperty('INTABL_SHEET_ID',id); } const savedUrl=props.getProperty('INTABL_SHOP_URL') || ''; if(/^https:\/\/[a-z0-9-]+\.2828\.pp\.ua\/?$/.test(savedUrl)){ props.setProperty('INTABL_SHOP_URL',savedUrl.replace('.2828.pp.ua','.intabl.com')); } return props; } function connectIntablShop() { try { intablCheckCatalogue_(); } catch(error) { SpreadsheetApp.getUi().alert(error.message); return; } const lock=LockService.getScriptLock();lock.waitLock(5000); let connectUrl; try{ const props=intablProperties_(); intablResetRevoked_(props); if(props.getProperty('INTABL_CREDENTIAL')){connectUrl=INTABL_ADMIN+'/?lang=en';} else { const now=Math.floor(Date.now()/1000); if(!props.getProperty('INTABL_NONCE') || Number(props.getProperty('INTABL_EXPIRES') || 0)<=now){ props.setProperties({INTABL_NONCE:Utilities.getUuid().replace(/-/g,'')+Utilities.getUuid().replace(/-/g,''),INTABL_EXPIRES:String(now+1700)}); props.deleteProperty('INTABL_CODE'); } if(!props.getProperty('INTABL_CODE')){ const installation=intablRequest_('/installations/start','post',{ spreadsheet_id:props.getProperty('INTABL_SHEET_ID'),nonce:props.getProperty('INTABL_NONCE'),google_access_token:ScriptApp.getOAuthToken() }); props.setProperties({INTABL_CODE:installation.code,INTABL_EXPIRES:String(installation.expires_at)}); } connectUrl=INTABL_ADMIN+'/connect?lang=en&code='+encodeURIComponent(props.getProperty('INTABL_CODE')); } } finally {lock.releaseLock();} const view=HtmlService.createTemplateFromFile('Connect');view.connectUrl=connectUrl; SpreadsheetApp.getUi().showModalDialog(view.evaluate().setWidth(460).setHeight(380),'INTABL'); } function pollIntablConnection() { const lock=LockService.getScriptLock();lock.waitLock(5000); try{ const props=intablProperties_(); const code=props.getProperty('INTABL_CODE'),nonce=props.getProperty('INTABL_NONCE'); if(props.getProperty('INTABL_CREDENTIAL')){ const shop=intablRequest_('/shop/connection','get',undefined,props.getProperty('INTABL_CREDENTIAL')); if(shop.spreadsheet_id!==props.getProperty('INTABL_SHEET_ID')){throw new Error('This key belongs to another spreadsheet.');} if(code && nonce){ try{intablRequest_('/installations/'+code+'/ack','post',{},nonce);}catch(_){/* Credential is already safely saved; acknowledgement can expire. */} } return {status:'connected',shop_url:shop.shop_url}; } if(!code || !nonce){throw new Error('Run Connect store again.');} const status=intablRequest_('/installations/'+code+'/status','get',undefined,nonce); if(status.status!=='approved'){return status;} const shop=intablRequest_('/installations/'+code+'/claim','post',{},nonce); if(shop.spreadsheet_id!==props.getProperty('INTABL_SHEET_ID')){throw new Error('This connection belongs to another spreadsheet.');} props.setProperties({INTABL_CREDENTIAL:shop.credential,INTABL_SHOP_URL:shop.shop_url,INTABL_SHOP_ID:String(shop.shop_id)}); // Save first, acknowledge second: losing an HTTP response must not lose the credential. try{intablRequest_('/installations/'+code+'/ack','post',{},nonce);}catch(_){} return {status:'connected',shop_url:shop.shop_url}; } finally {lock.releaseLock();} } function checkIntablConnection() { try{ const props=intablProperties_(); if(props.getProperty('INTABL_CREDENTIAL')){ const shop=intablRequest_('/shop/connection','get',undefined,props.getProperty('INTABL_CREDENTIAL')); if(shop.spreadsheet_id!==props.getProperty('INTABL_SHEET_ID')){throw new Error('This key belongs to another spreadsheet.');} SpreadsheetApp.getUi().alert('Store connected: '+shop.shop_url+'\nProducts are loaded from goods* sheets.'); }else{intablRequest_('/ready');SpreadsheetApp.getUi().alert('The Intabl connection is working. This spreadsheet is not connected to a store yet.');} }catch(error){SpreadsheetApp.getUi().alert(error.message);} } function checkIntablCatalogue() { try { intablCheckCatalogue_(); SpreadsheetApp.getUi().alert('The catalog is publicly accessible. You can connect your store.'); } catch(error) { SpreadsheetApp.getUi().alert(error.message); } } function intablCheckCatalogue_() { const ss=SpreadsheetApp.getActiveSpreadsheet(); const parts=intablAllGoods_();intablRefreshCatalogSheets_(); const goods=parts[0].sheet; if(!goods) { throw new Error('The goods sheet is missing. Copy the store template and keep this sheet name unchanged.'); } const expected=['id','name','description','price','image-url','tags']; const headers=goods.getRange(1,1,1,6).getDisplayValues()[0]; if(expected.some(function(name,i){return headers[i]!==name;})) { throw new Error('Restore the first-row headers in goods: '+expected.join(', ')+'.'); } // No Authorization header: test exactly the public access required by buyers. const url='https://docs.google.com/spreadsheets/d/'+encodeURIComponent(ss.getId())+'/gviz/tq?sheet='+encodeURIComponent(goods.getName())+'&range=A1%3AF1&headers=0&tqx=out%3Ajson'; const response=UrlFetchApp.fetch(url,{method:'get',muteHttpExceptions:true,followRedirects:false}); const match=response.getContentText().match(/google\.visualization\.Query\.setResponse\(([\s\S]*)\);?\s*$/); let result; try { result=match && JSON.parse(match[1]); } catch(_) { result=null; } const cells=result && result.table && result.table.rows && result.table.rows[0] && result.table.rows[0].c; if(response.getResponseCode()!==200 || !result || result.status!=='ok' || !cells || expected.some(function(name,i){return !cells[i] || cells[i].v!==name;})) { throw new Error('The catalog is not publicly accessible. Choose Share β Anyone with the link β Viewer, save and try again. Keep only public catalog information in this spreadsheet. If sharing is already enabled, try again later.'); } return true; } function openIntablShop() { const url=intablProperties_().getProperty('INTABL_SHOP_URL'); if(!url){SpreadsheetApp.getUi().alert('Connect your store first.');return;} intablLink_(url,'Open store'); } function openIntablAdmin(){intablLink_(INTABL_ADMIN,'Open dashboard');} function intablResetRevoked_(props){ const token=props.getProperty('INTABL_CREDENTIAL');if(!token)return; const r=UrlFetchApp.fetch(INTABL_API+'/shop/connection',{method:'get',muteHttpExceptions:true,followRedirects:false,headers:{Authorization:'Bearer '+token,'X-Intabl-Script-Version':INTABL_SCRIPT_VERSION}}); if(r.getResponseCode()===401){ let body;try{body=JSON.parse(r.getContentText());}catch(_){return;} if(body.error && body.error.code==='INVALID_CREDENTIAL'){ ['INTABL_CREDENTIAL','INTABL_CODE','INTABL_NONCE','INTABL_EXPIRES','INTABL_SHOP_ID','INTABL_SHOP_URL'].forEach(function(k){props.deleteProperty(k);}); } } } function intablLink_(url,label){ if(!/^https:\/\/[a-z0-9-]+\.intabl\.com\/?$/.test(url)){throw new Error('Invalid store address.');} SpreadsheetApp.getUi().showModalDialog(HtmlService.createHtmlOutput('
').setWidth(350).setHeight(120),'INTABL'); } // Only public sheet names. No product copies, credentials or personal data. function intablGoodsSheets_() { const sheets=SpreadsheetApp.getActiveSpreadsheet().getSheets().filter(s=>s.getName().startsWith('goods')); if(!sheets.length)throw new Error('Add at least one sheet whose name starts with goods.'); if(sheets.length>20)throw new Error('Up to 20 goods sheets are supported.'); return sheets; } function intablCategoryParts_() { const sheet=SpreadsheetApp.getActiveSpreadsheet().getSheetByName('category'); if(!sheet)return []; const height=Math.max(1,sheet.getLastRow()),width=Math.max(1,sheet.getLastColumn()); if(height>1001||width>26)throw new Error('category: up to 1000 categories and 26 columns.'); const values=sheet.getRange(1,1,height,width).getValues(),headers=values.shift(); if(!headers.some(v=>String(v).trim().toLowerCase()==='id'))throw new Error('category: missing id column'); const idCol=headers.map(v=>String(v).trim().toLowerCase()).indexOf('id'); values.forEach((row,index)=>{const id=String(row[idCol]??'').trim();if(['hit','new','sale'].includes(id.toLowerCase()))throw new Error('category β row '+(index+2)+': ID Β«'+id+' is reserved for a product badge. Change the category ID and its references in tags.');}); return [{sheet,headers,rows:values}]; } function intablRefreshCatalogSheets_() { const ss=SpreadsheetApp.getActiveSpreadsheet(),names=intablGoodsSheets_().map(s=>[s.getName()]); let manifest=ss.getSheetByName('_intabl_catalog'); if(manifest && manifest.getRange(1,1).getValue()!=='intabl-catalog-v1')throw new Error('The name _intabl_catalog is reserved. Rename your sheet.'); const rows=[['intabl-catalog-v1'],...names]; if(manifest && JSON.stringify(manifest.getRange(1,1,Math.max(1,manifest.getLastRow()),1).getValues())===JSON.stringify(rows))return; if(!manifest)manifest=ss.insertSheet('_intabl_catalog'); const writeRows=rows.concat(Array.from({length:Math.max(0,manifest.getLastRow()-rows.length)},()=>[''])); manifest.getRange(1,1,writeRows.length,1).setValues(writeRows); manifest.hideSheet(); } function refreshIntablCatalogSheets() { try{intablRefreshCatalogSheets_();SpreadsheetApp.getUi().alert('Product sheet list updated. Changes will appear after Google refreshes its cache.');} catch(error){SpreadsheetApp.getUi().alert(error.message);} } function onEdit(e) { if(!e || !e.range || !(e.range.getSheet().getName().startsWith('goods') || e.range.getSheet().getName()==='category'))return; intablRefreshCatalogSheets_(); const sheet=e.range.getSheet(),headers=sheet.getRange(1,1,1,Math.max(1,sheet.getLastColumn())).getValues()[0]; const col=headers.map(v=>String(v).trim().toLowerCase()).indexOf('id')+1; if(e.range.getRow()===1 || (col && e.range.getColumn()<=col && e.range.getLastColumn()>=col))intablCheckIds_(false); } function intablAllGoods_() { const sheets=intablGoodsSheets_();let count=0; const parts=sheets.map(sheet=>{ const height=Math.max(1,sheet.getLastRow()),width=sheet.getLastColumn();count+=height-1; if(count>5000||width>26)throw new Error('Catalog: up to 5000 rows across all goods sheets and 26 columns per sheet.'); const values=sheet.getRange(1,1,height,Math.max(1,width)).getValues(),headers=values.shift(); const keys=headers.map(v=>String(v).trim().toLowerCase()),idCol=keys.indexOf('id'); for(const key of ['id','name','price'])if(!keys.includes(key))throw new Error(sheet.getName()+': missing column '+key); return {sheet,headers,rows:values}; }); const checked=parts.concat(intablCategoryParts_()); const duplicates=intablDuplicateIds_(checked);intablMarkDuplicateIds_(checked,duplicates); if(duplicates.length)throw new Error(intablDuplicateMessage_(duplicates)); return parts; } function intablDuplicateIds_(parts){ const ids=new Map(); for(const part of parts){const col=part.headers.map(v=>String(v).trim().toLowerCase()).indexOf('id');if(col<0)continue; part.rows.forEach((row,i)=>{const id=String(row[col]??'').trim();if(!id)return;if(!ids.has(id))ids.set(id,[]);ids.get(id).push({sheet:part.sheet,row:i+2,col:col+1});}); } return Array.from(ids,([id,cells])=>({id,cells})).filter(item=>item.cells.length>1); } function intablDuplicateMessage_(duplicates){ return 'Duplicate product IDs:\n'+duplicates.slice(0,20).map(item=>'Β«'+item.id+'Β»: '+item.cells.slice(0,20).map(cell=>cell.sheet.getName()+' β row '+cell.row).join('; ')+(item.cells.length>20?' β¦':'')).join('\n')+(duplicates.length>20?'\nAnd '+(duplicates.length-20)+' more duplicate IDs.':'')+'\nFix the IDs highlighted in red. IDs must be unique across goods* and category sheets.'; } function intablMarkDuplicateIds_(parts,duplicates){ const marker='intabl-duplicate-id-v1',bySheet=new Map(); duplicates.forEach(item=>item.cells.forEach(cell=>{if(!bySheet.has(cell.sheet))bySheet.set(cell.sheet,[]);bySheet.get(cell.sheet).push(cell);})); for(const part of parts){ const sheet=part.sheet,old=sheet.getConditionalFormatRules(); const rules=old.filter(rule=>{const condition=rule.getBooleanCondition();return !condition || !condition.getCriteriaValues().some(value=>String(value).includes(marker));}); const cells=bySheet.get(sheet)||[]; if(cells.length){ const column=n=>{let name='';for(;n;n=Math.floor((n-1)/26))name=String.fromCharCode(65+(n-1)%26)+name;return name;}; const ranges=sheet.getRangeList(cells.map(cell=>column(cell.col)+cell.row)).getRanges(); rules.unshift(SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied('=N("'+marker+'")=0').setBackground('#fce8e6').setFontColor('#b91c1c').setRanges(ranges).build()); } if(cells.length || rules.length!==old.length)sheet.setConditionalFormatRules(rules); } } function intablCheckIds_(showMessage){ const parts=intablGoodsSheets_().map(sheet=>{const height=Math.max(1,sheet.getLastRow()),width=Math.max(1,sheet.getLastColumn());const headers=sheet.getRange(1,1,1,width).getValues()[0],col=headers.map(v=>String(v).trim().toLowerCase()).indexOf('id');if(col<0)throw new Error(sheet.getName()+': missing id column');const values=height>1?sheet.getRange(2,col+1,height-1,1).getValues():[];return {sheet,headers:['id'],rows:values,actualCol:col+1};}); for(const part of intablCategoryParts_()){part.actualCol=part.headers.map(v=>String(v).trim().toLowerCase()).indexOf('id')+1;parts.push(part);} const duplicates=intablDuplicateIds_(parts);duplicates.forEach(item=>item.cells.forEach(cell=>{cell.col=parts.find(part=>part.sheet===cell.sheet).actualCol;})); intablMarkDuplicateIds_(parts,duplicates); if(showMessage)SpreadsheetApp.getUi().alert(duplicates.length?intablDuplicateMessage_(duplicates):'No duplicate IDs. Checked goods* and category.'); return duplicates; } function checkIntablIds(){try{intablCheckIds_(true);}catch(error){SpreadsheetApp.getUi().alert(error.message);}} function intablOptionColumns_(headers){ const groups=[],costs=new Map(),seen=new Set(); for(let index=0;index