225 lines
9.0 KiB
JavaScript
225 lines
9.0 KiB
JavaScript
/**
|
|
* 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') || '';
|
|
}
|