diff --git a/WME-Place-Harmonizer.js b/WME-Place-Harmonizer.js index 1677faf..3069bfd 100644 --- a/WME-Place-Harmonizer.js +++ b/WME-Place-Harmonizer.js @@ -189,15 +189,29 @@ const SCRIPT_NAME = GM_info.script.name; const IS_BETA_VERSION = /Beta/i.test(SCRIPT_NAME); // enables dev messages and unique DOM options if the script is called "... Beta" const BETA_VERSION_STR = IS_BETA_VERSION ? 'Beta' : ''; // strings to differentiate DOM elements between regular and beta script - const PNH_DATA = { USA: {}, CAN: {} }; + const PNH_DATA = { + USA: { + countryCode: 'USA', + countryName: 'USA', + /** @type {PnhCategoryInfos} */ + categoryInfos: null, + /** @type {PnhEntry[]} */ + pnh: null + }, + CAN: { + countryCode: 'CAN', + countryName: 'Canada', + /** @type {PnhCategoryInfos} */ + categoryInfos: null, + /** @type {PnhEntry[]} */ + pnh: null + } + }; const DEFAULT_HOURS_TEXT = 'Paste hours here'; const MAX_CACHE_SIZE = 25000; const PROD_DOWNLOAD_URL = 'https://greasyfork.org/scripts/28690-wme-place-harmonizer/code/WME%20Place%20Harmonizer.user.js'; const BETA_DOWNLOAD_URL = 'YUhSMGNITTZMeTluY21WaGMzbG1iM0pyTG05eVp5OXpZM0pwY0hSekx6STROamc1TFhkdFpTMXdiR0ZqWlMxb1lYSnRiMjVwZW1WeUxXSmxkR0V2WTI5a1pTOVhUVVVsTWpCUWJHRmpaU1V5TUVoaGNtMXZibWw2WlhJbE1qQkNaWFJoTG5WelpYSXVhbk09'; - const _pnhModerators = {}; - - let _wordVariations; let _resultsCache = {}; let _initAlreadyRun = false; // This is used to skip a couple things if already run once. This could probably be handled better... let _textEntryValues = null; // Store the values entered in text boxes so they can be re-added when the banner is reassembled. @@ -581,6 +595,7 @@ CAT.SWAMP_MARSH, CAT.TUNNEL ]; + const dec = s => atob(atob(s)); // Split out state-based data let _psStateIx; @@ -792,6 +807,953 @@ } }; + class PnhCategoryInfos { + #categoriesById = {}; + #categoriesByName = {}; + + add(categoryInfo) { + this.#categoriesById[categoryInfo.id] = categoryInfo; + this.#categoriesByName[categoryInfo.name.toUpperCase()] = categoryInfo; + } + + getById(id) { + return this.#categoriesById[id]; + } + + getByName(name) { + return this.#categoriesByName[name.toUpperCase()]; + } + + toArray() { + return Object.values(this.#categoriesById); + } + } + + class PnhEntry { + /** @type {string} */ + order; + + /** @type {string */ + name; + + /** @type {string[]} */ + aliases; + + /** @type {string} */ + primaryCategory; + + /** @type {string[]} */ + altCategories; + + /** @type {string} */ + description; + + /** @type {string} */ + url; + + /** @type {string} */ + notes; + + /** @type {string[]} */ + regions; + + /** + * If this is true, the PNH entry should be ignored. + * @type {boolean} + * */ + disabled; + + /** @type {Symbol} */ + forceCategoryMatching; + + flagsToAdd = {}; + + flagsToRemove = {}; + + /** @type {string[]} */ + servicesToAdd = []; + + /** @type {string[]} */ + servicesToRemove = []; + + /** @type {string} */ + forceBrand; + + /** @type {RegExp} */ + localUrlCheckRegEx; + + /** @type {RegExp} */ + localizationRegEx; + + /** @type {string} */ + recommendedPhone; + + /** + * Prevent name change + * @type {boolean} + */ + keepName = false; + + /** @type {string} */ + optionalAlias; + + /** @type {boolean} */ + chainIsClosed; + + /** + * Value is -1 if no value has been set in PNH. + * @type {number} + */ + brandParentLevel = -1; + + /** @type {boolean} */ + strMatchAny; + + /** @type {string[]} */ + spaceMatchList; + + /** @type {boolean} */ + pharmhours; + + /** @type {boolean} */ + notABank; + + /** @type {boolean} */ + optionCat2; + + /** @type {boolean} */ + optionName2; + + /** @type {boolean} */ + altName2Desc; + + /** @type {boolean} */ + subFuel; + + /** @type {RegExp} */ + regexNameMatch; + + /** @type {number} */ + lockAt; + + /** @type {boolean} */ + noUpdateAlias; + + /** @type {boolean} */ + betaEnable; + + /** @type {string[]} */ + searchnameword; + + /** @type {string[]} */ + searchNameList; + + /** @type {boolean} */ + hasSpecialCases = false; + + /** + * true if the PNH entry is invalid and should be skipped + * @type {boolean} + */ + invalid = false; + + /** + * + * @param {string[]} columnHeaders + * @param {string} rowString A pipe-separated string with all of the PNH entry's data + * @param {PnhCategoryInfos} categoryInfos + */ + constructor(columnHeaders, rowString, categoryInfos) { + const parseResult = this.#parseSpreadsheetRow(columnHeaders, rowString, categoryInfos); + if (!this.invalid && (!this.disabled || this.betaEnable)) { + this.#buildSearchNameList(parseResult); + } + } + + /** + * Makes a string uppercase, then removes AND (anywhere), THE (only at the beginning), + * and any non-alphanumeric characters. + * @param {string} str + */ + static #tighten(str) { + return str.toUpperCase().replace(/ AND /g, '').replace(/^THE /g, '').replace(/[^A-Z0-9]/g, ''); + } + + /** + * Makes a string uppercase and removes any non-alphanumeric characters except for commas. + * @param {string} str + */ + static #stripNonAlphaKeepCommas(str) { + return str.toUpperCase().replace(/[^A-Z0-9,]/g, ''); + } + + /** + * + * @param {string[]} columnHeaders + * @param {string} rowString + * @param {PnhCategoryInfos} categoryInfos + * @returns + */ + #parseSpreadsheetRow(columnHeaders, rowString, categoryInfos) { + /** Contains values needed for immediate processing, but not to be stored in the PnhEntry */ + const result = { + searchnamebase: null, + searchnamemid: null, + searchnameend: null, + skipAltNameMatch: null, + warningMessages: [] + }; + + try { + const columnValues = rowString.split('|'); + + // Do any preprocessing here: + const disabled = columnValues[columnHeaders.indexOf(Pnh.SSHeader.disable)].trim(); + if (disabled === '1') { + // If the row is disabled, no need to process the rest of it. + this.disabled = true; + return result; + } + + // Step through columns and process the row values. + columnHeaders.forEach((header, i) => { + try { + if (Pnh.COLUMNS_TO_IGNORE.includes(header)) return; + + // If an invalid value is found, don't bother parsing the rest of the row data. + if (!this.invalid) { + let value = columnValues[i].trim(); + if (!value.length) { + value = undefined; + } else if (header === Pnh.SSHeader.aliases) { + // TODO: Are these two checks really needed? + if (value.startsWith('(')) { + value = undefined; // ignore aliases if the cell starts with paren + } else { + value = value.replace(/,[^A-za-z0-9]*/g, ','); // tighten up commas if more than one alias. + } + } + + switch (header) { + case Pnh.SSHeader.order: + case Pnh.SSHeader.description: + case Pnh.SSHeader.notes: + case Pnh.SSHeader.displaynote: + case Pnh.SSHeader.sfurl: + case Pnh.SSHeader.sfurllocal: + header = header.substring(3); + this[header] = value; + break; + case Pnh.SSHeader.url: + if (value) this.url = normalizeURL(value); + break; + case Pnh.SSHeader.searchnamebase: + result.searchnamebase = value; + break; + case Pnh.SSHeader.searchnamemid: + result.searchnamemid = value; + break; + case Pnh.SSHeader.searchnameend: + result.searchnameend = value; + break; + case Pnh.SSHeader.searchnameword: + this.searchnameword = value?.toUpperCase().replace(/, /g, ',').split(','); + break; + case Pnh.SSHeader.name: + if (value?.toUpperCase() !== 'PLEASE REUSE') { + this.name = value; + } else { + // No need to post warning here. Just skip it. + this.invalid = true; + } + break; + case Pnh.SSHeader.aliases: + this.aliases = value?.split(',').map(v => v.trim()) || []; + break; + case Pnh.SSHeader.category1: + if (value) { + this.primaryCategory = categoryInfos.getByName(value)?.id; + if (typeof this.primaryCategory === 'undefined') { + result.warningMessages.push(`Unrecognized primary category value: ${value}`); + } + } else { + result.warningMessages.push('No primary category assigned. PNH entry will be ignored!'); + this.invalid = true; + } + break; + case Pnh.SSHeader.category2: + this.altCategories = value?.split(',').map(v => v.trim()).map(catName => { + const cat = categoryInfos.getByName(catName)?.id; + if (!cat) { + result.warningMessages.push(`Unrecognized alternate category: ${catName}`); + } + return cat; + }).filter(cat => typeof cat === 'string'); + break; + case Pnh.SSHeader.region: + if (value) { + this.regions = value.toUpperCase().split(',').map(v => v.trim()); + // TODO: Check for valid regions. + } else { + // If no regions, ignore it. + this.invalid = true; + result.warningMessages.push('No regions specified. PNH entry will be ignored!'); + } + break; + case Pnh.SSHeader.disable: + // Handled the '1' case earlier in preprocessing + if (value === 'altName') { + result.skipAltNameMatch = true; + } else if (value) { + result.warningMessages.push(`Unrecognized value in ${Pnh.SSHeader.disable} column: ${value}`); + } + return; + case Pnh.SSHeader.forcecat: + if (!value || value === '0') { + this.forceCategoryMatching = Pnh.ForceCategoryMatchingType.NONE; + } else if (value === '1') { + this.forceCategoryMatching = Pnh.ForceCategoryMatchingType.PRIMARY; + } else if (value === '2') { + this.forceCategoryMatching = Pnh.ForceCategoryMatchingType.ANY; + } else { + result.warningMessages.push(`Unrecognized value in ${Pnh.SSHeader.forcecat} column: ${value}`); + } + break; + case Pnh.SSHeader.speccase: + if (value) { + this.hasSpecialCases = true; + value = value.split(',').map(v => v.trim()); + /* eslint-disable no-cond-assign */ + value.forEach(specialCase => { + let match; + if (match = specialCase.match(/^buttOn_(.*)/i)) { + const [, scFlag] = match; + switch (scFlag) { + case 'addCat2': + // flag = new Flag.AddCat2(); + break; + case 'addPharm': + case 'addSuper': + case 'appendAMPM': + case 'addATM': + case 'addConvStore': + this.flagsToAdd[scFlag] = true; + break; + default: + result.warningMessages.push(`Unrecognized ph_specCase value: ${specialCase}`); + } + } else if (match = specialCase.match(/^buttOff_(.+)/i)) { + const [, scFlag] = match; + switch (scFlag) { + case 'addConvStore': + this.flagsToRemove[scFlag] = true; + break; + default: + result.warningMessages.push(`Unrecognized ph_specCase value: ${specialCase}`); + } + // } else if (match = specCase.match(/^messOn_(.+)/i)) { + // [, scFlag] = match; + // _buttonBanner[scFlag].active = true; + // } else if (match = specCase.match(/^messOff_(.+)/i)) { + // [, scFlag] = match; + // _buttonBanner[scFlag].active = false; + } else if (match = specialCase.match(/^psOn_(.+)/i)) { + const [, scFlag] = match; + // TODO: Add check for valid services. + this.servicesToAdd.push(scFlag); + } else if (match = specialCase.match(/^psOff_(.+)/i)) { + const [, scFlag] = match; + // TODO: Add check for valid services. + this.servicesToRemove.push(scFlag); + } else if (match = specialCase.match(/forceBrand<>([^,<]+)/i)) { + // If brand is going to be forced, use that. Otherwise, use existing brand. + [, this.forceBrand] = match; + } else if (match = specialCase.match(/^localURL_(.+)/i)) { + // parseout localURL data if exists (meaning place can have a URL distinct from the chain URL + [, this.localURLcheck] = new RegExp(match, 'i'); + } else if (match = specialCase.match(/^checkLocalization<>(.+)/i)) { + const [, localizationString] = match; + this.localizationRegEx = new RegExp(localizationString, 'g'); + } else if (match = specialCase.match(/phone<>(.*?)<>/)) { + [, this.recommendedPhone] = match; + } else if (/keepName/g.test(specialCase)) { + this.keepName = true; + } else if (match = specialCase.match(/^optionAltName<>(.+)/i)) { + [, this.optionalAlias] = match; + } else if (/^closed$/i.test(specialCase)) { + this.chainIsClosed = true; + } else if (match = specialCase.match(/^brandParent(\d+)/)) { + try { + this.brandParentLevel = parseInt(match[1], 10); + } catch { + result.warningMessages.push(`Invalid forceBrand value: ${specialCase}`); + } + } else if (/^strMatchAny$/i.test(specialCase)) { + this.strMatchAny = true; + } else if (/^pharmhours$/i.test(specialCase)) { + this.pharmhours = true; + } else if (/^notABank$/i.test(specialCase)) { + this.notABank = true; + } else if (/^optionCat2$/i.test(specialCase)) { + this.optionCat2 = true; + } else if (/^optionName2$/i.test(specialCase)) { + this.optionName2 = true; + } else if (/^altName2Desc$/i.test(specialCase)) { + this.altName2Desc = true; + } else if (/^subFuel$/i.test(specialCase)) { + this.subFuel = true; + } else if (match = specialCase.match(/^regexNameMatch<>(.+)<>/i)) { + this.regexNameMatch = new RegExp(match[1].replace(/\\/, '\\').replace(//g, '|'), 'i'); + } else if (match = specialCase.match(/^lockAt(\d)$/i)) { + try { + this.lockAt = parseInt(match[1], 10); + if (this.lockAt < 1 || this.lockAt > 6) { + throw new Error(); + } + } catch { + result.warningMessages.push(`Invalid ph_speccase lockAt value (must be between 1 and 6): ${specialCase}`); + } + } else if (/^noUpdateAlias$/i.test(specialCase)) { + this.noUpdateAlias = true; + } else if (/^betaEnable$/i.test(specialCase)) { + this.betaEnable = true; + } else { + result.warningMessages.push(`Unrecognized ph_speccase value: ${specialCase}`); + } + }); + /* eslint-enable no-cond-assign */ + } + break; + case '': // Ignore this + break; + default: + // Ignore unrecognized headers here. + } + } + } catch (ex) { + result.warningMessages.push(`An unexpected error occurred while processing column: ${header}. PNH entry will be ignored.`); + } + }); // END ROW PROCESSING + + // Do any post-processing of row values here: + if (this.strMatchAny || this.primaryCategory === CAT.HOTEL) { + // NOTE: the replace functions here are not the same as the #tighten function, so don't use that. + this.spaceMatchList = [this.name.toUpperCase().replace(/ AND /g, ' ').replace(/^THE /g, '').replace(/[^A-Z0-9 ]/g, ' ').replace(/ {2,}/g, ' ')]; + if (this.searchnameword) { + this.spaceMatchList.push(...this.searchnameword); + } + } + } catch (ex) { + result.warningMessages.push(`An unexpected error occurred while parsing. PNH entry will be ignored! :\n${ex.toString()}`); + this.disabled = true; + } + + if (result.warningMessages.length) { + console.warn('WMEPH:', `PNH Order # ${this.order} parsing issues:\n- ${result.warningMessages.join('\n- ')}`); + } + return result; + } + + #buildSearchNameList(parseResult) { + let newNameList = [PnhEntry.#tighten(this.name)]; + + if (!parseResult.skipAltNameMatch) { + // Add any aliases + newNameList = newNameList.concat(this.aliases.map(alias => PnhEntry.#tighten(alias))); + } + + // The following code sets up alternate search names as outlined in the PNH dataset. + // Formula, with P = PNH primary; A1, A2 = PNH aliases; B1, B2 = base terms; M1, M2 = mid terms; E1, E2 = end terms + // Search list will build: P, A, B, PM, AM, BM, PE, AE, BE, PME, AME, BME. + // Multiple M terms are applied singly and in pairs (B1M2M1E2). Multiple B and E terms are applied singly (e.g B1B2M1 not used). + // Any doubles like B1E2=P are purged at the end to eliminate redundancy. + if (!isNullOrWhitespace(parseResult.searchnamebase)) { // If base terms exist, otherwise only the primary name is matched + newNameList = newNameList.concat(PnhEntry.#stripNonAlphaKeepCommas(parseResult.searchnamebase).split(',')); + + if (!isNullOrWhitespace(parseResult.searchnamemid)) { + let pnhSearchNameMid = PnhEntry.#stripNonAlphaKeepCommas(parseResult.searchnamemid).split(','); + if (pnhSearchNameMid.length > 1) { // if there are more than one mid terms, it adds a permutation of the first 2 + pnhSearchNameMid = pnhSearchNameMid + .concat([pnhSearchNameMid[0] + pnhSearchNameMid[1], pnhSearchNameMid[1] + pnhSearchNameMid[0]]); + } + const midLen = pnhSearchNameMid.length; + // extend the list by adding Mid terms onto the SearchNameBase names + for (let extix = 1, len = newNameList.length; extix < len; extix++) { + for (let midix = 0; midix < midLen; midix++) { + newNameList.push(newNameList[extix] + pnhSearchNameMid[midix]); + } + } + } + + if (!isNullOrWhitespace(parseResult.searchnameend)) { + const pnhSearchNameEnd = PnhEntry.#stripNonAlphaKeepCommas(parseResult.searchnameend).split(','); + const endLen = pnhSearchNameEnd.length; + // extend the list by adding End terms onto all the SearchNameBase & Base+Mid names + for (let extix = 1, len = newNameList.length; extix < len; extix++) { + for (let endix = 0; endix < endLen; endix++) { + newNameList.push(newNameList[extix] + pnhSearchNameEnd[endix]); + } + } + } + } + + // Clear out any empty entries + newNameList = newNameList.filter(name => name.length > 1); + + // Next, add extensions to the search names based on the WME place category + const categoryInfo = this.primaryCategory; + const appendWords = []; + if (categoryInfo) { + if (categoryInfo.id === CAT.HOTEL) { + appendWords.push('HOTEL'); + } else if (categoryInfo.id === CAT.BANK_FINANCIAL && !this.notABank) { + appendWords.push('BANK', 'ATM'); + } else if (categoryInfo.id === CAT.SUPERMARKET_GROCERY) { + appendWords.push('SUPERMARKET'); + } else if (categoryInfo.id === CAT.GYM_FITNESS) { + appendWords.push('GYM'); + } else if (categoryInfo.id === CAT.GAS_STATION) { + appendWords.push('GAS', 'GASOLINE', 'FUEL', 'STATION', 'GASSTATION'); + } else if (categoryInfo.id === CAT.CAR_RENTAL) { + appendWords.push('RENTAL', 'RENTACAR', 'CARRENTAL', 'RENTALCAR'); + } + appendWords.forEach(word => { newNameList = newNameList.concat(newNameList.map(name => name + word)); }); + } + + // Add entries for word/spelling variations + Pnh.WORD_VARIATIONS.forEach(variationsList => addSpellingVariants(newNameList, variationsList)); + + this.searchNameList = uniq(newNameList); + } + + /** + * Function that checks current place against the Harmonization Data. Returns place data or "NoMatch" + * @param {string} name + * @param {string} state2L + * @param {string} region3L + * @param {string} country + * @param {string[]} categories + * @param {venue} venue + * @returns + */ + getMatchInfo(name, state2L, region3L, country, categories, venue, venueNameSpace) { + const matchInfo = { + isMatch: false, + allowMultiMatch: true, // TODO: This can probably be removed + matchOutOfRegion: false + }; + let nameMatch = false; + + // Name Matching + if (this.regexNameMatch) { + nameMatch = this.regexNameMatch.test(venue.attributes.name); + } else if (this.strMatchAny || this.primaryCategory === CAT.HOTEL) { + // Match any part of WME name with either the PNH name or any spaced names + matchInfo.allowMultiMatch = true; // TODO: This can probably be removed + + for (let nmix = 0; nmix < this.spaceMatchList.length; nmix++) { + if (venueNameSpace.includes(` ${this.spaceMatchList[nmix]} `)) { + nameMatch = true; + break; + } + } + } else { + // Split all possible search names for the current PNH entry + const { searchNameList } = this; + + // Clear non-letter characters for alternate match ( HOLLYIVYPUB23 --> HOLLYIVYPUB ) + const venueNameNoNum = name.replace(/[^A-Z]/g, ''); + + /* + * I could not find strMatchStart or strMatchEnd in the PNH spreadsheet. Assuming these + * are no longer needed. + */ + // if (specCases.includes('strMatchStart')) { + // // Match the beginning part of WME name with any search term + // for (let nmix = 0; nmix < searchNameList.length; nmix++) { + // if (name.startsWith(searchNameList[nmix]) || venueNameNoNum.startsWith(searchNameList[nmix])) { + // PNHStringMatch = true; + // } + // } + // } else if (specCases.includes('strMatchEnd')) { + // // Match the end part of WME name with any search term + // for (let nmix = 0; nmix < searchNameList.length; nmix++) { + // if (name.endsWith(searchNameList[nmix]) || venueNameNoNum.endsWith(searchNameList[nmix])) { + // PNHStringMatch = true; + // } + // } + /* } else */ if (searchNameList.includes(name) || searchNameList.includes(venueNameNoNum)) { + // full match of any term only + nameMatch = true; + } + } + + // if a match was found: + if (nameMatch) { // Compare WME place name to PNH search name list + logDev(`Matched PNH Order No.: ${this.order}`); + + const PNHPriCat = this.primaryCategory; // Primary category of PNH data + let PNHForceCat = this.forceCategoryMatching; // Primary category of PNH data + + // Gas stations only harmonized if the WME place category is already gas station (prevents Costco Gas becoming Costco Store) + if (categories[0] === CAT.GAS_STATION) { + PNHForceCat = Pnh.ForceCategoryMatchingType.PRIMARY; + } + + // Name and primary category match + matchInfo.isMatch = (PNHForceCat === Pnh.ForceCategoryMatchingType.PRIMARY && categories.indexOf(PNHPriCat) === 0) + // Name and any category match + || (PNHForceCat === Pnh.ForceCategoryMatchingType.ANY && categories.includes(PNHPriCat)) + // Name only match + || (PNHForceCat === Pnh.ForceCategoryMatchingType.NONE); + } + + if (!(this.regions.includes(state2L) || this.regions.includes(region3L) // if the WME-selected venue matches the state, region + || this.regions.includes(country) // OR if the country code is in the data then it is approved for all regions therein + || $('#WMEPH-RegionOverride').prop('checked'))) { // OR if region override is selected (dev setting) + matchInfo.matchOutOfRegion = true; + } + + return matchInfo; + } + } + + /** "Namespace" for classes and methods related to handling PNH spreadsheet data */ + class Pnh { + static SPREADSHEET_ID = '1pBz4l4cNapyGyzfMJKqA4ePEFLkmz2RryAt1UV39B4g'; + static SPREADSHEET_RANGE = '2019.01.20.001!A2:L'; + static SPREADSHEET_MODERATORS_RANGE = 'Moderators!A1:F'; + static API_KEY = 'YTJWNVBVRkplbUZUZVVObU1YVXpSRVZ3ZW5OaFRFSk1SbTR4VGxKblRURjJlRTFYY3pOQ2NXZElPQT09'; + /** Columns that can be ignored when importing */ + static COLUMNS_TO_IGNORE = ['temp_field', 'ph_services', 'ph_national', 'logo', '']; + static WORD_VARIATIONS = null; + static MODERATORS = {}; + + static ForceCategoryMatchingType = Object.freeze({ + NONE: Symbol('none'), + PRIMARY: Symbol('primary'), + ANY: Symbol('any') + }); + + static SSHeader = Object.freeze({ + order: 'ph_order', + name: 'ph_name', + aliases: 'ph_aliases', + category1: 'ph_category1', + category2: 'ph_category2', + description: 'ph_description', + url: 'ph_url', + notes: 'ph_notes', + region: 'ph_region', + disable: 'ph_disable', + forcecat: 'ph_forcecat', + displaynote: 'ph_displaynote', + speccase: 'ph_speccase', + searchnamebase: 'ph_searchnamebase', + searchnamemid: 'ph_searchnamemid', + searchnameend: 'ph_searchnameend', + searchnameword: 'ph_searchnameword', + sfurl: 'ph_sfurl', + sfurllocal: 'ph_sfurllocal', + toValueArray: () => Object.values(Pnh.SSHeader).filter(v => typeof v === 'string') + }); + + /** + * Function that checks current place against the Harmonization Data. Returns place data, "NoMatch", or "Approval Needed" + * @param {string} name + * @param {string} state2L + * @param {string} region3L + * @param {string} country + * @param {string[]} categories + * @param {venue} venue + * @returns + */ + static findMatch(name, state2L, region3L, country, categories, venue) { + if (country !== PNH_DATA.USA.countryCode && country !== PNH_DATA.CAN.countryCode) { + WazeWrap.Alerts.info(SCRIPT_NAME, 'No PNH data exists for this country.'); + return ['NoMatch']; + } + if (venue.isParkingLot()) { + return ['NoMatch']; + } + /** @type {PnhEntry[]} */ + const pnhData = PNH_DATA[country].pnh; + const matchPNHRegionData = []; // array of matched data with regional approval + const pnhOrderNum = []; + const pnhNameTemp = []; + let matchOutOfRegion = false; // tracks match status + let matchInRegion = false; + + name = name.toUpperCase().replace(/ AND /g, ' ').replace(/^THE /g, ''); + const venueNameSpace = ` ${name.replace(/[^A-Z0-9 ]/g, ' ').replace(/ {2,}/g, ' ')} `; + name = name.replace(/[^A-Z0-9]/g, ''); // Clear all non-letter and non-number characters ( HOLLYIVY PUB #23 -- > HOLLYIVYPUB23 ) + + // for each entry in the PNH list (skipping headers at index 0) + for (let pnhIdx = 0; pnhIdx < pnhData.length; pnhIdx++) { + const pnhEntry = pnhData[pnhIdx]; + const matchInfo = pnhEntry.getMatchInfo(name, state2L, region3L, country, categories, venue, venueNameSpace); + if (matchInfo.isMatch) { + // if (!matchInfo.allowMultiMatch) { + // return [pnhEntry]; + // } + matchInRegion = true; + if (matchInfo.matchOutOfRegion) { + // PNH match found (once true, stays true) + matchOutOfRegion = true; + // temp name for approval return + pnhNameTemp.push(pnhEntry.name); + + // temp order number for approval return + pnhOrderNum.push(pnhEntry.order); + } else { + matchPNHRegionData.push(pnhEntry); + } + } + } // END loop through PNH entries + + // If name & region match was found: + if (matchInRegion) { + return matchPNHRegionData; + } + if (matchOutOfRegion) { // if a name match was found but not for region, prod the user to get it approved + return ['ApprovalNeeded', pnhNameTemp, pnhOrderNum]; + } + if (matchPNHRegionData.length) { + return matchOutOfRegion; + } + // if no match was found, suggest adding the place to the sheet if it's a chain + return ['NoMatch']; + } + + static validatePnhSSColumnHeaders(headers) { + let valid = true; + const expectedHeaders = Pnh.SSHeader.toValueArray(); + + // Warn if extra headers are found in the spreadsheet. + headers.forEach(header => { + // temp_field currently exists on the USA sheet but may not be needed + if (header.length && header !== 'temp_field' && !expectedHeaders.includes(header) + && !Pnh.COLUMNS_TO_IGNORE.includes(header)) { + console.warn(`WMEPH: Unexpected column header found in PNH spreadsheet: ${header}`); + } + }); + + // Return invalid if expected headers are not found in spreadsheet. + expectedHeaders.forEach(header => { + if (!headers.includes(header)) { + console.error(`WMEPH: Column header missing from PNH spreadsheet data: ${header}`); + valid = false; + } + }); + + return valid; + } + + /** + * + * @param {string[]} rows + * @param {PnhCategoryInfos} categoryInfos + * @returns + */ + static processPnhSSRows(rows, categoryInfos) { + const columnHeaders = rows.splice(0, 1)[0].split('|').map(h => h.trim()); + + // Canada's spreadsheet is missing 'ph_order' in the first column header. + if (!columnHeaders[0].length) columnHeaders[0] = Pnh.SSHeader.order; + + if (!Pnh.validatePnhSSColumnHeaders(columnHeaders)) { + throw new Error('WMEPH: WMEPH exiting due to missing spreadsheet column headers.'); + } + return rows.map(row => new PnhEntry(columnHeaders, row, categoryInfos)) + .filter(entry => !entry.disabled && !entry.invalid); + } + + static processImportedDataColumn(allData, columnIndex) { + return allData.filter(row => row.length >= columnIndex + 1).map(row => row[columnIndex]); + } + + static getSpreadsheetUrl(id, range, key) { + return `https://sheets.googleapis.com/v4/spreadsheets/${id}/values/${range}?${dec(key)}`; + } + + static downloadPnhData() { + log('PNH data download started...'); + return new Promise((resolve, reject) => { + const url = this.getSpreadsheetUrl(this.SPREADSHEET_ID, this.SPREADSHEET_RANGE, this.API_KEY); + + $.getJSON(url).done(res => { + const { values } = res; + if (values[0][0].toLowerCase() === 'obsolete') { + WazeWrap.Alerts.error(SCRIPT_NAME, 'You are using an outdated version of WMEPH that doesn\'t work anymore. Update or disable the script.'); + return; + } + + // This needs to be performed before makeNameCheckList() is called. + Pnh.WORD_VARIATIONS = Pnh.processImportedDataColumn(values, 11).slice(1).map(row => row.toUpperCase().replace(/[^A-z0-9,]/g, '').split(',')); + + PNH_DATA.USA.categoryInfos = new PnhCategoryInfos(); + Pnh.processCategories(Pnh.processImportedDataColumn(values, 3), PNH_DATA.USA.categoryInfos); + PNH_DATA.USA.pnh = Pnh.processPnhSSRows(Pnh.processImportedDataColumn(values, 0), PNH_DATA.USA.categoryInfos); + + // PNH_DATA.USA.pnhNames = makeNameCheckList(PNH_DATA.USA); + PNH_DATA.states = Pnh.processImportedDataColumn(values, 1); + + PNH_DATA.CAN.categoryInfos = PNH_DATA.USA.categoryInfos; + PNH_DATA.CAN.pnh = Pnh.processPnhSSRows(Pnh.processImportedDataColumn(values, 2), PNH_DATA.CAN.categoryInfos); + + const WMEPHuserList = Pnh.processImportedDataColumn(values, 4)[1].split('|'); + const betaix = WMEPHuserList.indexOf('BETAUSERS'); + _wmephDevList = []; + _wmephBetaList = []; + for (let ulix = 1; ulix < betaix; ulix++) _wmephDevList.push(WMEPHuserList[ulix].toLowerCase().trim()); + for (let ulix = betaix + 1; ulix < WMEPHuserList.length; ulix++) _wmephBetaList.push(WMEPHuserList[ulix].toLowerCase().trim()); + + const processTermsCell = (termsValues, colIdx) => Pnh.processImportedDataColumn(termsValues, colIdx)[1] + .toLowerCase().split('|').map(value => value.trim()); + _hospitalPartMatch = processTermsCell(values, 5); + _hospitalFullMatch = processTermsCell(values, 6); + _animalPartMatch = processTermsCell(values, 7); + _animalFullMatch = processTermsCell(values, 8); + _schoolPartMatch = processTermsCell(values, 9); + _schoolFullMatch = processTermsCell(values, 10); + + log('PNH data download completed'); + resolve(); + }).fail(res => { + const message = res.responseJSON && res.responseJSON.error ? res.responseJSON.error : 'See response error message above.'; + console.error('WMEPH failed to load spreadsheet:', message); + reject(); + }); + }); + } + + static downloadPnhModerators() { + log('PNH moderators download started...'); + return new Promise(resolve => { + const url = Pnh.getSpreadsheetUrl(Pnh.SPREADSHEET_ID, Pnh.SPREADSHEET_MODERATORS_RANGE, Pnh.API_KEY); + + $.getJSON(url).done(res => { + const { values } = res; + + try { + values.forEach(regionArray => { + const region = regionArray[0]; + const mods = regionArray.slice(3); + Pnh.MODERATORS[region] = mods; + }); + } catch (ex) { + Pnh.MODERATORS['?'] = ['Error downloading moderators!']; + } + + // delete Texas region, if it exists + delete Pnh.MODERATORS.TX; + + log('PNH moderators download completed'); + resolve(); + }).fail(res => { + const message = res.responseJSON && res.responseJSON.error ? res.responseJSON.error : 'See response error message above.'; + console.error('WMEPH failed to load moderator list:', message); + Pnh.MODERATORS['?'] = ['Error downloading moderators!']; + resolve(); + }); + }); + } + + static processCategories(categoryDataRows, categoryInfos) { + let headers; + let pnhServiceKeys; + let wmeServiceIds; + const splitValues = (value => (value.trim() ? value.split(',').map(v => v.trim()) : [])); + categoryDataRows.forEach((row, iRow) => { + row = row.split('|'); + if (iRow === 0) { + headers = row; + } else if (iRow === 1) { + pnhServiceKeys = row; + } else if (iRow === 2) { + wmeServiceIds = row; + } else { + const categoryInfo = { + services: [] + }; + row.forEach((value, iCol) => { + const headerValue = headers[iCol].trim(); + value = value.trim(); + switch (headerValue) { + case 'pc_wmecat': + categoryInfo.id = value; + break; + case 'pc_transcat': + categoryInfo.name = value; + break; + case 'pc_catparent': + categoryInfo.parent = value; + break; + case 'pc_point': + categoryInfo.point = value; + break; + case 'pc_area': + categoryInfo.area = value; + break; + case 'pc_regpoint': + categoryInfo.regPoint = splitValues(value); + break; + case 'pc_regarea': + categoryInfo.regArea = splitValues(value); + break; + case 'pc_lock1': + categoryInfo.lock1 = splitValues(value); + break; + case 'pc_lock2': + categoryInfo.lock2 = splitValues(value); + break; + case 'pc_lock3': + categoryInfo.lock3 = splitValues(value); + break; + case 'pc_lock4': + categoryInfo.lock4 = splitValues(value); + break; + case 'pc_lock5': + categoryInfo.lock5 = splitValues(value); + break; + case 'pc_rare': + categoryInfo.rare = splitValues(value); + break; + case 'pc_parent': + categoryInfo.disallowedParent = splitValues(value); + break; + case 'pc_message': + categoryInfo.messagae = value; + break; + case 'ps_valet': + case 'ps_drivethru': + case 'ps_wifi': + case 'ps_restrooms': + case 'ps_cc': + case 'ps_reservations': + case 'ps_outside': + case 'ps_ac': + case 'ps_parking': + case 'ps_deliveries': + case 'ps_takeaway': + case 'ps_wheelchair': + if (value) { + categoryInfo.services.push({ wmeId: wmeServiceIds[iCol], pnhKey: pnhServiceKeys[iCol] }); + } + break; + case '': + // ignore blank column + break; + default: + throw new Error(`WMEPH: Unexpected category data from PNH sheet: ${headerValue}`); + } + }); + categoryInfos.add(categoryInfo); + } + }); + } + } + // KB Shortcut object const SHORTCUT = { allShortcuts: {}, // All the shortcuts are stored in this array @@ -1140,104 +2102,6 @@ return [...new Set(arrayIn)]; } - // This function runs at script load, and builds the search name dataset to compare the WME selected place name to. - function makeNameCheckList(countryData) { - const pnhData = countryData.pnh; - const headers = pnhData[0].split('|'); - const nameIdx = headers.indexOf('ph_name'); - const aliasesIdx = headers.indexOf('ph_aliases'); - const category1Idx = headers.indexOf('ph_category1'); - const searchNameBaseIdx = headers.indexOf('ph_searchnamebase'); - const searchNameMidIdx = headers.indexOf('ph_searchnamemid'); - const searchNameEndIdx = headers.indexOf('ph_searchnameend'); - const disableIdx = headers.indexOf('ph_disable'); - const specCaseIdx = headers.indexOf('ph_speccase'); - const tighten = str => str.toUpperCase().replace(/ AND /g, '').replace(/^THE /g, '').replace(/[^A-Z0-9]/g, ''); - const stripNonAlphaKeepCommas = str => str.toUpperCase().replace(/[^A-Z0-9,]/g, ''); - - return pnhData.map(entry => { - const splits = entry.split('|'); - const specCase = splits[specCaseIdx]; - - if (splits[disableIdx] !== '1' || specCase.includes('betaEnable')) { - let newNameList = [tighten(splits[nameIdx])]; - - if (splits[disableIdx] !== 'altName') { - // Add any aliases - const tempAliases = splits[aliasesIdx]; - if (!isNullOrWhitespace(tempAliases)) { - newNameList = newNameList.concat(tempAliases.replace(/,[^A-Za-z0-9]*/g, ',').split(',').map(alias => tighten(alias))); - } - } - - // The following code sets up alternate search names as outlined in the PNH dataset. - // Formula, with P = PNH primary; A1, A2 = PNH aliases; B1, B2 = base terms; M1, M2 = mid terms; E1, E2 = end terms - // Search list will build: P, A, B, PM, AM, BM, PE, AE, BE, PME, AME, BME. - // Multiple M terms are applied singly and in pairs (B1M2M1E2). Multiple B and E terms are applied singly (e.g B1B2M1 not used). - // Any doubles like B1E2=P are purged at the end to eliminate redundancy. - const nameBaseStr = splits[searchNameBaseIdx]; - if (!isNullOrWhitespace(nameBaseStr)) { // If base terms exist, otherwise only the primary name is matched - newNameList = newNameList.concat(stripNonAlphaKeepCommas(nameBaseStr).split(',')); - - const nameMidStr = splits[searchNameMidIdx]; - if (!isNullOrWhitespace(nameMidStr)) { - let pnhSearchNameMid = stripNonAlphaKeepCommas(nameMidStr).split(','); - if (pnhSearchNameMid.length > 1) { // if there are more than one mid terms, it adds a permutation of the first 2 - pnhSearchNameMid = pnhSearchNameMid.concat([pnhSearchNameMid[0] + pnhSearchNameMid[1], pnhSearchNameMid[1] + pnhSearchNameMid[0]]); - } - const midLen = pnhSearchNameMid.length; - // extend the list by adding Mid terms onto the SearchNameBase names - for (let extix = 1, len = newNameList.length; extix < len; extix++) { - for (let midix = 0; midix < midLen; midix++) { - newNameList.push(newNameList[extix] + pnhSearchNameMid[midix]); - } - } - } - - const nameEndStr = splits[searchNameEndIdx]; - if (!isNullOrWhitespace(nameEndStr)) { - const pnhSearchNameEnd = stripNonAlphaKeepCommas(nameEndStr).split(','); - const endLen = pnhSearchNameEnd.length; - // extend the list by adding End terms onto all the SearchNameBase & Base+Mid names - for (let extix = 1, len = newNameList.length; extix < len; extix++) { - for (let endix = 0; endix < endLen; endix++) { - newNameList.push(newNameList[extix] + pnhSearchNameEnd[endix]); - } - } - } - } - // Clear out any empty entries - newNameList = newNameList.filter(name => name.length > 1); - - // Next, add extensions to the search names based on the WME place category - const categoryInfo = countryData.categoryInfos.getByName(splits[category1Idx]); - const appendWords = []; - if (categoryInfo) { - if (categoryInfo.id === CAT.HOTEL) { - appendWords.push('HOTEL'); - } else if (categoryInfo.id === CAT.BANK_FINANCIAL && !/\bnotABank\b/.test(specCase)) { - appendWords.push('BANK', 'ATM'); - } else if (categoryInfo.id === CAT.SUPERMARKET_GROCERY) { - appendWords.push('SUPERMARKET'); - } else if (categoryInfo.id === CAT.GYM_FITNESS) { - appendWords.push('GYM'); - } else if (categoryInfo.id === CAT.GAS_STATION) { - appendWords.push('GAS', 'GASOLINE', 'FUEL', 'STATION', 'GASSTATION'); - } else if (categoryInfo.id === CAT.CAR_RENTAL) { - appendWords.push('RENTAL', 'RENTACAR', 'CARRENTAL', 'RENTALCAR'); - } - appendWords.forEach(word => { newNameList = newNameList.concat(newNameList.map(name => name + word)); }); - } - - // Add entries for word/spelling variations - _wordVariations.forEach(variationsList => addSpellingVariants(newNameList, variationsList)); - - return uniq(newNameList).join('|').replace(/\|{2,}/g, '|').replace(/\|+$/g, ''); - } // END if valid line - return '00'; - }); - } // END makeNameCheckList - function clickGeneralTab() { // Make sure the General tab is selected before clicking on the external provider element. // These selector strings are very specific. Could probably make them more generalized for robustness. @@ -1431,157 +2295,6 @@ $('head').append($('