Library used between the Add/Edit page and the View page.
La data de
Acest script nu ar trebui instalat direct. Aceasta este o bibliotecă pentru alte scripturi care este inclusă prin directiva meta a // @require https://update.greasyfork.org/scripts/555040/1690714/USPS%20Address%20Validation%20-%20Common.js
// ==UserScript==
// @name USPS Address Validation - Common
// @namespace https://github.com/nate-kean/
// @version 2025.11.6.1
// @description Library used between the Add/Edit page and the View page.
// @author Nate Kean
// @match https://jamesriver.fellowshiponego.com/members/add*
// @match https://jamesriver.fellowshiponego.com/members/edit/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=fellowshiponego.com
// @grant none
// @license MIT
// ==/UserScript==
// @ts-check
document.head.insertAdjacentHTML("beforeend", `
<style id="data-team-address-validator-css">
.address-panel {
position: relative;
}
.data-team-address-validation-indicator {
float: right;
font-size: 16px;
font-weight: 600;
width: 24px;
height: 24px;
text-align: center;
padding-top: 4px;
}
.data-team-address-validation-indicator.fa-check {
color: #00c853;
}
.data-team-address-validation-indicator.fa-exclamation {
color: #ff8f00;
cursor: pointer;
background-color: hsla(0, 0%, 100%, .1);
border-radius: 6px;
transition: background-color 100ms;
}
.data-team-address-validation-indicator.fa-times {
color: #c84040
}
.data-team-address-validation-indicator + .tooltip > .tooltip-inner {
max-width: 250px !important;
}
</style>
`);
/**
* @typedef {Object} QueriedAddress
* @property {string} streetAddress
* @property {string} city
* @property {string} state
* @property {string} zip
* @property {string} country
*/
/**
* @typedef {Object} CanonicalAddress
* @property {string} streetAddress
* @property {string} city
* @property {string} state
* @property {string} zip5
* @property {string} zip4
*/
/**
* Validation Result
* @typedef {Object} ValResult
* @property {Validator.Code[keyof Validator.Code]} code
* @property {string} msg
* @property {number} corrs - correction count
* @property {CanonicalAddress?} address
*/
/**
* @param {number} ms
* @returns {Promise<void>}
*/
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* @param {string} str
* @returns {string}
*/
function toTitleCase(str) {
return str.replace(
/\w\S*/g,
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
);
}
class Validator {
static Code = Object.freeze({
__proto__: null,
MATCH: 0,
CORRECTION: 1,
NOT_FOUND: 2,
NOPE: 3,
ERROR: 4,
NOT_IMPL: 5,
});
static #USPS_API_CLIENT_ID = "6mnicGgTpkmQ3gkf6Nr7Ati8NHhGc4tuGTwca3v4AsPGKIBL";
static #USPS_API_CLIENT_SECRET = "IUvAMfzOAAuDAn23yAylO1J9Y3MvE8AtDywW6SDPpvrazGmAvwOHLgJWs4Gkoy2w";
static #DEFAULT_BACKOFF = 4000;
static #backoff = Validator.#DEFAULT_BACKOFF;
/**
* Call when there is a new address to validate.
* @param {Indicator} indicator
* @param {QueriedAddress} address
* @returns {Promise<void>}
*/
static async onNewAddressQuery(indicator, address) {
const cached = Validator.#getFromCache(address);
if (cached !== null) {
indicator.onValidationResult(cached);
return;
};
const result = await Validator.#validate(address);
Validator.#sendToCache(address, result);
indicator.onValidationResult(result);
}
/**
* @param {QueriedAddress} address
* @returns {Promise<ValResult>}
*/
static async #validate({ streetAddress, city, state, zip, country }) {
// We have to check this ourselves because USPS very curiously returns
// HTTP 400 if it's not right. (Like. why not just return a correction?)
if (state !== state.toUpperCase()) {
const [zip4, zip5] = zip.split("-");
return {
code: Validator.Code.CORRECTION,
msg: (
`${streetAddress.replace("\n", "<br>")}<br>${city}, `
+ `<strong>${state.toUpperCase()}</strong> ${zip}`
),
corrs: 1,
address: { streetAddress, city, state, zip4, zip5 },
};
}
if (country.length == 2 && country !== "US")
return { code: Validator.Code.NOPE, msg: "", corrs: 0, address: null, };
// Handle being timed out on a previous page
const prevBackoff = window.sessionStorage.getItem("ndk usps 402");
if (prevBackoff !== null) {
const prevBackoffDate = new Date(prevBackoff);
const prelimBackoff = (
prevBackoffDate.getMilliseconds()
- (new Date().getMilliseconds())
);
await delay(prelimBackoff);
window.sessionStorage.removeItem("ndk usps 402");
}
const accessToken = await Validator.#getAccessToken();
streetAddress = toTitleCase(streetAddress);
city = toTitleCase(city);
const zipParts = zip?.split("-") ?? [];
const zip5 = zipParts[0] ?? "";
const zip4 = zipParts[1] ?? "";
/**
* @type {Record<string, string>}
*/
const params = {};
// Only include entries that are populated; empty string causes HTTP 400
if (streetAddress) params.streetAddress = streetAddress;
if (city) params.city = city;
if (state) params.state = state;
if (zip5) params.ZIPCode = zip5;
if (zip4) params.ZIPPlus4 = zip4;
const payloadURL = (
"https://corsproxy.io/?url="
+ "https://apis.usps.com/addresses/v3/address?"
+ new URLSearchParams(params).toString()
);
const response = await fetch(payloadURL, {
headers: new Headers({
"Authorization": "Bearer " + accessToken,
}),
}
);
switch (response.status) {
case 200:
break;
case 401:
await Validator.#regenerateToken();
// @ts-ignore -- tuple type error nonsense
return await Validator.#validate(...arguments);
case 404:
return { code: Validator.Code.NOT_FOUND, msg: "", corrs: 0, address: null };
case 429:
case 503: {
// Exponential backoff retry
const timeoutDate = new Date();
timeoutDate.setMilliseconds(
timeoutDate.getMilliseconds() + Validator.#backoff
);
window.sessionStorage.setItem(
"ndk usps 402",
timeoutDate.toISOString()
);
await delay(Validator.#backoff);
Validator.#backoff **= 2;
// @ts-ignore
const validatePromise = Validator.#validate(...arguments);
Validator.#backoff = Validator.#DEFAULT_BACKOFF;
window.sessionStorage.removeItem("ndk usps 402");
return await validatePromise;
}
default:
return {
code: Validator.Code.ERROR,
msg: `USPS returned status code ${response.status}`,
corrs: 0,
address: null,
};
}
const json = await response.json();
let note = "";
let correctionCount = 0;
const code = json.corrections[0]?.code || json.matches[0]?.code;
switch (code) {
case "31":
break;
case "32":
note = "Missing apartment, suite, or box number.";
correctionCount++;
break;
case "22":
note = json.corrections[0].text;
correctionCount++;
break;
default:
return {
code: Validator.Code.NOT_IMPL,
msg: `Status code ${code} not implemented`,
corrs: 0,
address: null,
};
}
/**
* @type {CanonicalAddress}
*/
const canonicalAddr = {
streetAddress: toTitleCase(
`${json.address.streetAddress} ${json.address.secondaryAddress}`
).trim(),
city: toTitleCase(json.address.city),
state: json.address.state,
zip5: json.address.ZIPCode,
zip4: json.address.ZIPPlus4,
};
let new_addr = "";
if (canonicalAddr.streetAddress === streetAddress) {
new_addr += streetAddress;
} else {
new_addr += `<strong>${canonicalAddr.streetAddress}</strong>`;
correctionCount++;
}
new_addr += "<br>";
if (canonicalAddr.city === city) {
new_addr += city;
} else {
new_addr += `<strong>${canonicalAddr.city}</strong>`;
correctionCount++;
}
new_addr += ", ";
if (canonicalAddr.state === state) {
new_addr += state;
} else {
new_addr += `<strong>${canonicalAddr.state}</strong>`;
correctionCount++;
}
new_addr += " ";
if (canonicalAddr.zip5 === zip5 && canonicalAddr.zip4 === zip4) {
new_addr += `${zip5}-${zip4}`;
} else {
new_addr += `<strong>${canonicalAddr.zip5}-${canonicalAddr.zip4}</strong>`;
correctionCount++;
}
if (correctionCount > 0) {
return {
code: Validator.Code.CORRECTION,
msg: `<span>${new_addr}${note ? `<br><i>${note}</i>` : ""}</span>`,
corrs: correctionCount,
address: canonicalAddr,
};
} else {
return { code: Validator.Code.MATCH, msg: "", corrs: 0, address: canonicalAddr };
}
}
/**
* @returns {Promise<string | null>}
*/
static async #getAccessToken() {
let accessToken = window.localStorage.getItem("natesUSPSAccessToken");
if (accessToken === "null" || accessToken === null) {
await Validator.#regenerateToken();
accessToken = window.localStorage.getItem("natesUSPSAccessToken");
}
return accessToken;
}
/**
* @returns {Promise<void>}
*/
static async #regenerateToken() {
const response = await fetch(
"https://corsproxy.io/?url=https://apis.usps.com/oauth2/v3/token", {
method: "POST",
headers: new Headers({
"Content-Type": "application/json",
}),
body: JSON.stringify({
grant_type: "client_credentials",
scope: "addresses",
client_id: Validator.#USPS_API_CLIENT_ID,
client_secret: Validator.#USPS_API_CLIENT_SECRET,
}),
});
switch (response.status) {
case 200:
break;
case 429:
case 503: {
// Exponential backoff retry
const timeoutDate = new Date();
timeoutDate.setMilliseconds(
timeoutDate.getMilliseconds() + Validator.#backoff
);
window.sessionStorage.setItem(
"ndk usps 402",
timeoutDate.toISOString()
);
await delay(Validator.#backoff);
Validator.#backoff **= 2;
const validatePromise = Validator.#regenerateToken();
Validator.#backoff = Validator.#DEFAULT_BACKOFF;
window.sessionStorage.removeItem("ndk usps 402");
return await validatePromise;
}
default:
// @ts-ignore -- it wants string but i will give it objecte anyway
throw Error(response);
}
const json = await response.json();
window.localStorage.setItem("natesUSPSAccessToken", json.access_token);
}
/**
* @param {QueriedAddress} address
* @returns {string}
*/
static #serializeAddress({ streetAddress, city, state, zip, country }) {
return `ndk ${streetAddress} ${city} ${state} ${zip} ${country}`;
}
/**
* @param {QueriedAddress} address
* @returns {ValResult?}
*/
static #getFromCache(address) {
const key = Validator.#serializeAddress(address);
const value = window.sessionStorage.getItem(key);
if (value === null) return null;
return JSON.parse(value);
}
/**
* @param {QueriedAddress} address
* @param {ValResult} result
* @returns {void}
*/
static #sendToCache(address, result) {
if (
result.code === Validator.Code.ERROR
|| result.code === Validator.Code.NOT_IMPL
) return;
const key = Validator.#serializeAddress(address);
const value = JSON.stringify(result);
window.sessionStorage.setItem(key, value);
}
}
class Indicator {
#icon;
/**
* @param {Node} parent
*/
constructor(parent) {
/**
* @type {HTMLButtonElement}
*/
this.button = document.createElement("button");
/**
* @type {HTMLElement}
*/
this.#icon = document.createElement("i");
/**
* @type {ValResult?}
*/
this.status = null;
this.#icon.classList.add("data-team-address-validation-indicator");
this.#icon.setAttribute("data-toggle", "tooltip");
this.#icon.setAttribute("data-placement", "top");
this.#icon.setAttribute("data-html", "true");
this.#icon.classList.add("fal", "fa-spinner-third", "fa-spin");
// @ts-ignore -- environment will have jquery
$(this.#icon).tooltip();
this.button.appendChild(this.#icon);
parent.appendChild(this.button);
}
/**
* @param {ValResult} result
* @returns {void}
*/
onValidationResult(result) {
this.status = result;
const { code, msg, corrs: correctionCount } = this.status;
let tooltipContent = "";
this.#icon.classList.remove("fa-spinner-third", "fa-spin");
switch (code) {
case Validator.Code.MATCH:
this.#icon.classList.add("fa-check");
tooltipContent = "USPS — Verified valid";
break;
case Validator.Code.CORRECTION:
this.#icon.classList.add("fa-exclamation");
const s = correctionCount > 1 ? "s" : "";
tooltipContent = `USPS — Correction${s} suggested:<br>${msg}`;
break;
case Validator.Code.NOT_FOUND:
this.#icon.classList.add("fa-times");
tooltipContent = "USPS — Address not found";
break;
case Validator.Code.NOPE:
this.#icon.classList.add("fa-circle");
tooltipContent = "USPS validation skipped: incompatible country";
break;
case Validator.Code.ERROR:
this.#icon.classList.add("fa-times");
tooltipContent = `ERROR: ${msg}. Contact Nate`;
break;
case Validator.Code.NOT_IMPL:
this.#icon.classList.add("fa-times");
tooltipContent = `ERROR: ${msg}. Contact Nate`;
break;
default:
this.#icon.classList.add("fa-times");
tooltipContent = "PLUGIN ERROR: contact Nate";
break;
}
this.#icon.setAttribute("data-original-title", tooltipContent);
}
}