Backed up Williams-Sonoma Google Script

This commit is contained in:
Norm Rasmussen
2026-02-12 14:28:07 -05:00
parent 47248b74ab
commit 2b5f3d6b83
4 changed files with 442 additions and 0 deletions

View File

@ -0,0 +1,16 @@
{
"scriptId": "1GO6XQKg9AiO-CDXBNGZaAbznyXj--TzMBlcZBUe3cKU6Xnw-Vo6yQFhY",
"rootDir": "",
"scriptExtensions": [
".js",
".gs"
],
"htmlExtensions": [
".html"
],
"jsonExtensions": [
".json"
],
"filePushOrder": [],
"skipSubdirectories": false
}

View File

@ -0,0 +1,7 @@
{
"timeZone": "America/New_York",
"dependencies": {
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8"
}

View File

@ -0,0 +1,226 @@
/***************
* CONFIG
***************/
const CONFIG = {
SPREADSHEET_ID: "17II0V8EsTL-9nHq2HgqhHcay-t-hhOaY5wNEzXHEsAM",
SOURCE_SHEET_NAME: "WSMinMCA.csv", // tab name inside the spreadsheet
EMAIL_COLUMN_INDEX_1BASED: 3, // Column C = 3 (1-based)
SENDER: "nrasmussen@gainsight.com",
SUBJECT: "DATA REQUEST",
PROCESSED_LABEL: "DATA_REQUEST_PROCESSED",
TEMP_SHEET_PREFIX: "DATA_REQUEST_", // new tab prefix
MAX_THREADS_PER_RUN: 10 // safety throttle
};
/**
* Run once to create the time-driven trigger.
* (There is no true "email received" trigger for Gmail in Apps Script.)
*/
function setupTrigger() {
// Optional: delete existing triggers for this function to avoid duplicates
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === "processDataRequests")
.forEach(t => ScriptApp.deleteTrigger(t));
// Create a trigger every 5 minutes (adjust as desired)
ScriptApp.newTrigger("processDataRequests")
.timeBased()
.everyMinutes(5)
.create();
}
/**
* Main worker: finds unread "DATA REQUEST" messages from aubrey@ws.com,
* extracts target email from body, filters rows, creates new tab, CSVs it,
* replies with attachment, and labels thread as processed.
*/
function processDataRequests() {
// const label = GmailApp.getUserLabelByName(CONFIG.PROCESSED_LABEL) || GmailApp.createLabel(CONFIG.PROCESSED_LABEL);
// // Search unread matching threads that are not already labeled processed
// const query = [
// `from:${CONFIG.SENDER}`,
// `subject:"${CONFIG.SUBJECT}"`,
// "is:unread",
// `-label:${CONFIG.PROCESSED_LABEL}`
// ].join(" ");
const threads = GmailApp.search(query, 0, CONFIG.MAX_THREADS_PER_RUN);
Logger.log(threads);
// if (!threads.length) return;
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sourceSheet = ss.getSheetByName(CONFIG.SOURCE_SHEET_NAME);
if (!sourceSheet) throw new Error(`Sheet tab not found: ${CONFIG.SOURCE_SHEET_NAME}`);
// Read all data once for efficiency
const sourceValues = sourceSheet.getDataRange().getValues();
if (sourceValues.length < 1) throw new Error(`No data found in ${CONFIG.SOURCE_SHEET_NAME}`);
const header = sourceValues[0];
const emailColIdx0 = CONFIG.EMAIL_COLUMN_INDEX_1BASED - 1;
threads.forEach(thread => {
const messages = thread.getMessages();
// Process only unread messages in the thread (usually just the newest)
messages.forEach(message => {
if (!message.isUnread()) return;
try {
const plainBody = message.getPlainBody() || "";
const targetEmail = extractFirstNonSenderEmail_(plainBody, CONFIG.SENDER);
if (!targetEmail) {
message.reply(
`I received your DATA REQUEST, but I couldn't find a target email address in the message body.\n\n` +
`Please include an email like "someone@company.com" in the body and resend.`
);
message.markRead();
return;
}
const targetLower = targetEmail.toLowerCase();
// Filter rows where Column C exactly matches the extracted email (case-insensitive)
const matches = sourceValues
.slice(1) // skip header
.filter(row => String(row[emailColIdx0] || "").trim().toLowerCase() === targetLower);
// Create a new tab with results
const newSheetName = makeSafeSheetName_(
`${CONFIG.TEMP_SHEET_PREFIX}${targetLower}_${Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyyMMdd_HHmmss")}`
);
const resultSheet = ss.insertSheet(newSheetName);
resultSheet.getRange(1, 1, 1, header.length).setValues([header]);
if (matches.length) {
resultSheet.getRange(2, 1, matches.length, header.length).setValues(matches);
}
// Convert result tab to CSV
const csv = valuesToCsv_(resultSheet.getDataRange().getValues());
const blob = Utilities.newBlob(csv, "text/csv", `${targetLower}.csv`);
// Reply with CSV attached (keeps threading)
const body =
`Attached is the CSV export for ${targetEmail}.\n\n` +
`Rows matched (excluding header): ${matches.length}\n` +
`Source tab: ${CONFIG.SOURCE_SHEET_NAME}\n` +
`Result tab: ${newSheetName}\n`;
message.reply(body, { attachments: [blob] });
// Mark processed
message.markRead();
thread.addLabel(label);
// Optional cleanup: uncomment if you do NOT want to keep the result tab
// ss.deleteSheet(resultSheet);
} catch (err) {
// Fail gracefully: reply with error so the sender knows it didn't work
message.reply(
`I attempted to process this DATA REQUEST but hit an error:\n\n${err && err.stack ? err.stack : err}`
);
message.markRead();
}
});
});
}
function modifiedProcessDataRequests() {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sourceSheet = ss.getSheetByName(CONFIG.SOURCE_SHEET_NAME);
if (!sourceSheet) throw new Error(`Sheet tab not found: ${CONFIG.SOURCE_SHEET_NAME}`);
const targetEmail = "khodge2@wsgc.com";
const targetLower = targetEmail.toLowerCase();
// Read all data once for efficiency
const sourceValues = sourceSheet.getDataRange().getValues();
if (sourceValues.length < 1) throw new Error(`No data found in ${CONFIG.SOURCE_SHEET_NAME}`);
const header = sourceValues[0];
Logger.log(header);
const emailColIdx0 = CONFIG.EMAIL_COLUMN_INDEX_1BASED - 1;
Logger.log(emailColIdx0);
// Filter rows where Column C exactly matches the extracted email (case-insensitive)
const matches = sourceValues
.slice(1) // skip header
.filter(row => String(row[emailColIdx0] || "").trim().toLowerCase() === targetLower);
// Create a new tab with results
const newSheetName = makeSafeSheetName_(
`${CONFIG.TEMP_SHEET_PREFIX}${targetLower}_${Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyyMMdd_HHmmss")}`
);
const resultSheet = ss.insertSheet(newSheetName);
resultSheet.getRange(1, 1, 1, header.length).setValues([header]);
if (matches.length) {
resultSheet.getRange(2, 1, matches.length, header.length).setValues(matches);
}
// Convert result tab to CSV
const csv = valuesToCsv_(resultSheet.getDataRange().getValues());
const blob = Utilities.newBlob(csv, "text/csv", `${targetLower}.csv`);
// Reply with CSV attached (keeps threading)
const body =
`Attached is the CSV export for ${targetEmail}.\n\n` +
`Rows matched (excluding header): ${matches.length}\n` +
`Source tab: ${CONFIG.SOURCE_SHEET_NAME}\n` +
`Result tab: ${newSheetName}\n`;
Logger.log(body);
// Optional cleanup: uncomment if you do NOT want to keep the result tab
// ss.deleteSheet(resultSheet);
}
/****************
* HELPERS
****************/
/**
* Extracts the first email address found in the text that is NOT the sender email.
*/
function extractFirstNonSenderEmail_(text, senderEmail) {
const re = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig;
const found = text.match(re) || [];
const senderLower = (senderEmail || "").toLowerCase();
const first = found
.map(e => e.trim())
.find(e => e.toLowerCase() !== senderLower);
return first || null;
}
/**
* Converts a 2D array of values to RFC-ish CSV:
* - Wrap fields in quotes if they contain comma, quote, or newline
* - Double internal quotes
*/
function valuesToCsv_(values) {
return values.map(row => row.map(cell => {
let s = cell === null || cell === undefined ? "" : String(cell);
// Normalize line endings inside cells
s = s.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const needsQuotes = /[",\n]/.test(s);
if (needsQuotes) s = `"${s.replace(/"/g, '""')}"`;
return s;
}).join(",")).join("\r\n");
}
/**
* Google Sheets tab names have limits; keep it safe.
*/
function makeSafeSheetName_(name) {
// Remove characters Sheets doesn't like: [ ] : * ? / \
let safe = name.replace(/[\[\]:*?/\\]/g, " ");
safe = safe.replace(/\s+/g, " ").trim();
// Max length 100
if (safe.length > 100) safe = safe.slice(0, 100);
return safe || "DATA_REQUEST_RESULT";
}

View File

@ -0,0 +1,193 @@
/***********************
* PROPFIG (edit these)
***********************/
const PROPFIG = {
spreadsheetId: "17II0V8EsTL-9nHq2HgqhHcay-t-hhOaY5wNEzXHEsAM",
sheetId: 249063374, // the tab's sheetId (not gid string; usually same number)
courseCol: 6, // Column C = 3
headerRow: 1,
startDataRow: 2,
northpassBaseUrl: "https://api.northpass.com",
cacheTtlSeconds: 6 * 60 * 60 // 6 hours
};
/**
* One-time setup:
* Run setNorthpassApiKey_() to store your API key securely in Script Properties.
*/
function setNorthpassApiKey() {
const apiKey = "N5AQIwjIeeYDBSU9vTST37Nvg";
PropertiesService.getScriptProperties().setProperty("NORTHPASS_API_KEY", apiKey);
}
/**
* Main entrypoint: reads course names from col C and writes core_competencies
* into the first empty column (based on header row).
*/
function writeCoreCompetenciesFromCourses() {
const ss = SpreadsheetApp.openById(PROPFIG.spreadsheetId);
const sheet = getSheetById_(ss, PROPFIG.sheetId);
const apiKey = PropertiesService.getScriptProperties().getProperty("NORTHPASS_API_KEY");
if (!apiKey) throw new Error("Missing NORTHPASS_API_KEY. Run setNorthpassApiKey_() first.");
// Determine output column: first empty cell in header row; if none, append.
const lastCol = Math.max(sheet.getLastColumn(), 1);
const headerValues = sheet.getRange(PROPFIG.headerRow, 1, 1, lastCol).getValues()[0];
let outCol = headerValues.findIndex(v => String(v || "").trim() === "") + 1;
if (outCol === 0) outCol = lastCol + 1;
// Write a header label (optional but useful)
sheet.getRange(PROPFIG.headerRow, outCol).setValue("core_competencies");
// Read course column values
const lastRow = sheet.getLastRow();
if (lastRow < PROPFIG.startDataRow) return;
const numRows = lastRow - PROPFIG.startDataRow + 1;
const courseValues = sheet.getRange(PROPFIG.startDataRow, PROPFIG.courseCol, numRows, 1).getValues();
// Prep caches: in-run memo + script cache
const runMemo = {}; // { courseNameLower: { id, coreCompetenciesString } }
const cache = CacheService.getScriptCache();
// Build output column values aligned to rows
const output = new Array(numRows).fill([""]);
for (let i = 0; i < numRows; i++) {
const rawCourse = String(courseValues[i][0] || "").trim();
if (!rawCourse) continue;
const key = rawCourse.toLowerCase();
// 1) in-run memo
if (runMemo[key]) {
output[i] = [runMemo[key].coreCompetenciesString];
continue;
}
// 2) CacheService (persists across runs for TTL)
const cached = cache.get(cacheKey_(key));
if (cached) {
const parsed = JSON.parse(cached);
runMemo[key] = parsed;
output[i] = [parsed.coreCompetenciesString];
continue;
}
// 3) Fetch from Northpass
const courseId = fetchCourseIdByExactName_(apiKey, rawCourse);
const coreCompetencies = fetchCoreCompetenciesByCourseId_(apiKey, courseId);
const coreStr = normalizeToSingleString_(coreCompetencies);
const record = { id: courseId, coreCompetenciesString: coreStr };
runMemo[key] = record;
cache.put(cacheKey_(key), JSON.stringify(record), PROPFIG.cacheTtlSeconds);
output[i] = [coreStr];
}
// Write results in one batch
sheet.getRange(PROPFIG.startDataRow, outCol, numRows, 1).setValues(output);
}
/***********************
* Northpass API helpers
***********************/
/**
* Gets course id by exact name using:
* /v2/courses?filter[name][eq]=<courseName>
* If no match is returned, falls back to:
* /v2/courses?filter[name]=<courseName>
*
* Filtering is documented by Northpass. :contentReference[oaicite:3]{index=3}
*/
function fetchCourseIdByExactName_(apiKey, courseName) {
const encoded = encodeURIComponent(courseName);
const urlsToTry = [
`${PROPFIG.northpassBaseUrl}/v2/courses?filter[name][eq]=${encoded}`,
`${PROPFIG.northpassBaseUrl}/v2/courses?filter[name]=${encoded}` // fallback
];
for (const url of urlsToTry) {
const json = northpassGetJson_(apiKey, url);
// Typical Northpass JSON: { data: [ { id, type, attributes... }, ... ] } :contentReference[oaicite:4]{index=4}
const data = json && json.data;
if (Array.isArray(data) && data.length > 0 && data[0].id) {
return data[0].id;
}
}
throw new Error(`No course found for name "${courseName}" (tried eq + fallback).`);
}
/**
* Fetches course properties:
* /v2/properties/courses/{id}
* Returns: data.attributes.customized_properties.core_competencies :contentReference[oaicite:5]{index=5}
*/
function fetchCoreCompetenciesByCourseId_(apiKey, courseId) {
const url = `${PROPFIG.northpassBaseUrl}/v2/properties/courses/${encodeURIComponent(courseId)}`;
const json = northpassGetJson_(apiKey, url);
const core =
json &&
json.data &&
json.data.attributes &&
json.data.attributes.customized_properties &&
json.data.attributes.customized_properties.core_competencies;
// Could be undefined/null/string/array/object depending on how it's stored
return core ?? "";
}
/**
* Makes an authenticated GET request to Northpass using X-Api-Key header.
* Northpass API key auth is documented. :contentReference[oaicite:6]{index=6}
*/
function northpassGetJson_(apiKey, url) {
const resp = UrlFetchApp.fetch(url, {
method: "get",
muteHttpExceptions: true,
headers: {
"Accept": "application/json",
"X-Api-Key": apiKey
}
});
const code = resp.getResponseCode();
const text = resp.getContentText();
if (code < 200 || code >= 300) {
throw new Error(`Northpass API error ${code} for ${url}\n${text}`);
}
return JSON.parse(text);
}
/***********************
* Sheet helpers
***********************/
function getSheetById_(spreadsheet, sheetId) {
const sheets = spreadsheet.getSheets();
for (const s of sheets) {
if (s.getSheetId() === sheetId) return s;
}
throw new Error(`No sheet found with sheetId=${sheetId}`);
}
/***********************
* Formatting + caching
***********************/
function normalizeToSingleString_(value) {
if (value === null || value === undefined) return "";
if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(Boolean).join("; ");
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function cacheKey_(courseNameLower) {
return `np_course_${courseNameLower}`;
}