/** * Sheet-initiated pull integration. This is the only place data ever * moves between Bettersight and a PM's spreadsheet — always initiated * from inside the open Sheet (the sidebar's [Pull latest results] * button), never from the dashboard or Flask, since a stateless * backend has no way to reach into whichever Sheet a PM has open * (CLAUDE.md §15). */ const DEFAULT_TAB_NAME = 'Bettersight Analysis'; // Every STANDARD_FIELDS column's recognised header aliases, written // out explicitly rather than guessed at mid-scrape. The first alias // in each list is the canonical label used when writing a brand-new // header row. Matching is case- and punctuation-insensitive (see // normaliseHeader_), so "Rack Rate", "rack rate", and "RACK-RATE" all // match the same entry. const FIELD_ALIASES = { company: ['Company', 'Competitor', 'Competitor Name', 'Operator'], link: ['Link', 'URL', 'Page URL', 'Trip URL', 'Web Link'], tripName: ['Trip Name', 'Trip', 'Product Name', 'Tour Name'], tripCode: ['Trip Code', 'Code', 'Product Code', 'SKU'], serviceLevel: ['Service Level', 'Style', 'Accommodation Level', 'Class'], groupSize: ['Group Size', 'Max Group Size', 'Group', 'Party Size'], duration: ['Duration', 'Days', 'Length', 'Trip Length', 'Number of Days'], meals: ['Meals', 'Included Meals', 'Total Meals', 'Meal Count'], startLocation: ['Start Location', 'Departure City', 'Starts', 'Start City', 'Start Point'], endLocation: ['End Location', 'End City', 'Ends', 'Finish City', 'End Point'], departures: ['Departures', 'Departures Per Year', 'Frequency', 'Number of Departures'], seasonality: ['Seasonality', 'Season', 'Price Bands', 'Seasonal Pricing'], startDays: ['Start Days', 'Departure Days', 'Days of Week'], targetAudience: ['Target Audience', 'Audience', 'Traveller Type', 'Traveler Type'], hotels: ['Hotels', 'Accommodation', 'Hotel Type', 'Lodging'], activities: ['Activities', 'Included Activities', 'Key Activities', 'Highlights'], relevancy: ['Relevancy', 'Relevance', 'Match Score', 'Relevance Score'], majorityPrice: ['Majority Price', 'Standard Price', 'Rack Rate', 'Price', 'Price USD', 'Adult Price'], priceLow: ['Price Low', 'Low Price', 'Min Price', 'Lowest Price', 'Price From'], priceHigh: ['Price High', 'High Price', 'Max Price', 'Highest Price', 'Price To'], comments: ['Comments', 'Analysis', 'Notes', 'Observations'], dateUsed: ['Date Used', 'Departure Date', 'Date', 'Sample Date'], audPrice: ['AUD Price', 'AUD', 'Price AUD', 'Australian Dollar Price'], cadPrice: ['CAD Price', 'CAD', 'Price CAD', 'Canadian Dollar Price'], eurPrice: ['EUR Price', 'EUR', 'Price EUR', 'Euro Price'], gbpPrice: ['GBP Price', 'GBP', 'Price GBP', 'British Pound Price', 'Pound Price'] }; // Fallback column positions, used only when row 1 has no recognisable // headers at all (e.g. a brand-new blank tab). Ordered left to right // starting at column 1 — logs a warning when this path is taken. const STANDARD_FIELDS_ORDER = Object.keys(FIELD_ALIASES); function normaliseHeader_(text) { return String(text || '').toLowerCase().replace(/[^a-z0-9]/g, ''); } function canonicalLabel_(field) { return FIELD_ALIASES[field][0]; } /** * Returns every tab name in the currently open workbook — used by the * sidebar to populate its own tab selector. Never called by Flask; * this only ever runs inside the open Sheet. */ function getWorkbookTabs() { return SpreadsheetApp.getActiveSpreadsheet().getSheets().map(function (s) { return s.getName(); }); } /** * Detects the column index for each field by matching header names in * row 1 of the target sheet against FIELD_ALIASES — never hardcoded * column positions. Falls back to STANDARD_FIELDS_ORDER's left-to-right * positions only when row 1 has no recognisable headers at all — logs * a warning when that fallback is used. * * Data flow: * sheet → read row 1 → normalise each header cell → * for each field, check every alias in FIELD_ALIASES against every * header cell → first match wins → column index → * { fieldName: columnIndex } map returned */ function detectColumns(sheet) { const lastColumn = Math.max(sheet.getLastColumn(), 1); const headerRow = sheet.getRange(1, 1, 1, lastColumn).getValues()[0]; const normalisedHeaders = headerRow.map(normaliseHeader_); const columnMap = {}; Object.keys(FIELD_ALIASES).forEach(function (field) { const normalisedAliases = FIELD_ALIASES[field].map(normaliseHeader_); for (let col = 0; col < normalisedHeaders.length; col++) { if (normalisedHeaders[col] && normalisedAliases.indexOf(normalisedHeaders[col]) !== -1) { columnMap[field] = col + 1; // 1-indexed columns break; } } }); const foundAnyHeader = Object.keys(columnMap).length > 0; if (!foundAnyHeader) { Logger.log('detectColumns: no recognisable headers in row 1 — falling back to STANDARD_FIELDS_ORDER positions'); STANDARD_FIELDS_ORDER.forEach(function (field, i) { columnMap[field] = i + 1; }); } return columnMap; } /** * Writes the header row (if the sheet is empty) and appends one row * per competitor result, using columnMap from detectColumns(). Only * writes fields present in columnMap. */ function writeResultsToSheet_(results, columnMap, sheet) { if (sheet.getLastRow() === 0) { const headerCells = []; Object.keys(columnMap).forEach(function (field) { headerCells[columnMap[field] - 1] = canonicalLabel_(field); }); sheet.getRange(1, 1, 1, headerCells.length).setValues([headerCells.map(function (h) { return h || ''; })]); } let nextRow = sheet.getLastRow() + 1; const successItems = (results && results.success) || []; successItems.forEach(function (item) { const extracted = item.result || {}; const rowValues = {}; rowValues.company = item.name; rowValues.link = extracted.link || (item.product && item.product.url) || ''; Object.keys(FIELD_ALIASES).forEach(function (field) { if (field === 'company' || field === 'link') return; const value = extracted[field]; if (value !== undefined && value !== null) { rowValues[field] = value; } }); Object.keys(rowValues).forEach(function (field) { const col = columnMap[field]; if (col) { sheet.getRange(nextRow, col).setValue(rowValues[field]); } }); nextRow += 1; }); return successItems.length; } /** * Fetches the PM's most recent completed analysis from Flask and * writes it into the selected tab of the currently open workbook. * Sheet-initiated only — cannot be triggered by any external server. * * Data flow: * validateLicence() → confirms authorisation → * GET {BETTERSIGHT_API_BASE_URL}/research/history/latest?email=... → * stored results returned → target tab resolved (creates it if * missing) → detectColumns(sheet) → writeResultsToSheet_() → * last-pull status saved to UserProperties, sync_from_sheet * onboarding flag flipped on Flask on first success → * { success, rowsWritten, summary } returned to the sidebar */ function pullLatestResults(tabName) { if (!validateLicence()) { return { success: false, message: 'Licence check failed — see the alert for details.' }; } const email = Session.getActiveUser().getEmail(); let response; try { response = UrlFetchApp.fetch( `${BETTERSIGHT_API_BASE_URL}/research/history/latest?email=${encodeURIComponent(email)}`, { method: 'get', headers: { 'X-API-Key': BETTERSIGHT_API_KEY }, muteHttpExceptions: true } ); } catch (e) { return { success: false, message: 'Could not reach Bettersight — check your connection and try again.' }; } if (response.getResponseCode() === 404) { return { success: false, message: 'No completed analysis runs yet — run one from the dashboard first.' }; } if (response.getResponseCode() >= 400) { return { success: false, message: 'Bettersight returned an error fetching your latest results.' }; } const run = JSON.parse(response.getContentText()); const spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); const targetTabName = tabName || DEFAULT_TAB_NAME; let sheet = spreadsheet.getSheetByName(targetTabName); if (!sheet) { sheet = spreadsheet.insertSheet(targetTabName); } const columnMap = detectColumns(sheet); const rowsWritten = writeResultsToSheet_(run.results, columnMap, sheet); const summary = `${rowsWritten} competitor${rowsWritten === 1 ? '' : 's'}`; const timestamp = new Date().toLocaleString(); const statusLine = `Pulled: ${timestamp} · ${summary}`; PropertiesService.getUserProperties().setProperty('LAST_PULL_STATUS', statusLine); markOnboardingStepOnce_('sync_from_sheet'); return { success: true, rowsWritten: rowsWritten, summary: statusLine }; } /** * Returns the last-pull status line saved by pullLatestResults(), for * the sidebar to show immediately on open without waiting for a pull. */ function getLastPullStatus() { return PropertiesService.getUserProperties().getProperty('LAST_PULL_STATUS') || ''; }