Library used between the Add/Edit page and the View page.
As of
This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/555040/1693621/USPS%20Address%20Validation%20-%20Common.js
// ==UserScript==
// @name USPS Address Validation - Common
// @namespace https://github.com/nate-kean/
// @version 2025.11.11.2
// @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="jrc-address-validator-css">
.address-panel > .panel-heading {
width: 50% !important;
background-color: unset !important;
& > .panel-title {
padding-right: 50px;
display: flex;
justify-content: space-between;
}
}
button.jrc-address-validation-indicator {
float: right;
width: 32px;
height: 32px;
text-align: center;
padding: 0;
border: none;
margin-top: -4px;
& + .tooltip > .tooltip-inner {
max-width: 250px !important;
}
& > i {
margin-top: 3px;
font-size: 16px;
font-weight: 600;
}
& > i.fa-check {
color: #00c853;
}
& > i.fa-exclamation {
color: #ff8f00;
background-color: hsla(0, 0%, 100%, .1);
border-radius: 6px;
transition: background-color 100ms;
}
& > i.fa-times {
color: #c84040;
/* This icon is just smaller than the others for some reason */
font-size: 20px;
}
}
.address-2-header > .jrc-address-validation-indicator {
margin-right: 20px;
}
</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} message
* @property {number} correctionCount
* @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(),
);
}
/**
* @param {Element | Document} el
* @param {string} selector
* @returns {Element}
*/
function tryQuerySelector(el, selector) {
const maybeEl = el.querySelector(selector);
if (maybeEl === null) {
console.error(el);
throw new Error(`Failed to find '${selector}' in given element`);
}
return maybeEl;
}
/**
* This really turned into quite the behemoth
*/
class Validator {
static Code = Object.freeze({
__proto__: null,
MATCH: 0,
CORRECTION: 1,
NOT_FOUND: 2,
NOPE: 3,
ERROR: 4,
LOADING: 5,
EMPTY: 6,
NOT_IMPL: 7,
});
static #CLIENT_ID = "6mnicGgTpkmQ3gkf6Nr7Ati8NHhGc4tuGTwca3v4AsPGKIBL";
static #CLIENT_SECRET = "IUvAMfzOAAuDAn23yAylO1J9Y3MvE8AtDywW6SDPpvrazGmAvwOHLgJWs4Gkoy2w";
static #API_ROOT = "https://corsproxy.io/?url=https://apis.usps.com";
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) {
// Really a wrapper around #validate that uses cache
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(address) {
let { streetAddress, city, state, zip } = address;
const trivialChecksResult = Validator.#trivialChecks(address);
if (trivialChecksResult !== null) return trivialChecksResult;
await Validator.#pickUpTimeout();
const accessToken = await Validator.#getAccessToken();
// Normalize and format the address to prepare to send to USPS
streetAddress = toTitleCase(streetAddress);
// #'s confuse the API. Official USPS addresses will never contain #.
// USPS will correct "apartment" to "Apt", or "Unit", or whatever else
// it's supposed to be. We could replace #'s with "Apt" to keep from
// confusing the API, but we also want USPS to correct it in case it
// really is supposed to be "Apt", so we normalize them to "apartment"
// instead.
streetAddress = streetAddress.replaceAll("#", "apartment");
city = toTitleCase(city);
/**
* @type {Record<string, string>}
*/
const params = { streetAddress, city, state };
const zipParts = zip?.split("-") ?? [];
// Only include the ZIP code if it's properly formatted (as a 5 or 5+4).
// Otherwise the API throws an HTTP 400
if (zipParts[0]?.length === 5) {
params.ZIPCode = zipParts[0];
if (zipParts[1]?.length === 4) {
params.ZIPPlus4 = zipParts[1];
}
}
const response = await fetch(
`${Validator.#API_ROOT}/addresses/v3/address?${new URLSearchParams(params)}`,
{ headers: { "Authorization": `Bearer ${accessToken}` } },
);
const earlyResult = await Validator.#parseStatus(response);
if (earlyResult !== null) return earlyResult;
const json = await response.json();
return Validator.#makeCorrectionResult(json, address, zipParts);
}
static async #pickUpTimeout() {
// Handle being timed out on a previous page
const prevBackoff = window.sessionStorage.getItem("ndk retry");
if (prevBackoff !== null) {
const prevBackoffDate = new Date(prevBackoff);
const prelimBackoff = (
prevBackoffDate.getMilliseconds()
- (new Date().getMilliseconds())
);
await delay(prelimBackoff);
window.sessionStorage.removeItem("ndk retry");
}
}
/**
* @param {Response} response
* @returns {Promise<ValResult?>}
*/
static async #parseStatus(response) {
switch (response.status) {
case 200:
return null;
case 400: {
const json = await response.json();
switch (json.error.message) {
case "Address Not Found":
return {
code: Validator.Code.NOT_FOUND,
message: "",
correctionCount: 0,
address: null,
};
default:
console.error(json);
return {
code: Validator.Code.ERROR,
message: "USPS returned error True 400",
correctionCount: 0,
address: null,
};
}
}
case 401:
await Validator.#regenerateToken();
// @ts-ignore -- tuple type error nonsense
return await Validator.#validate(...arguments);
case 404:
return {
code: Validator.Code.NOT_FOUND,
message: "",
correctionCount: 0,
address: null,
};
case 429:
case 503:
return await Validator.#retry(
// @ts-ignore -- you can totally do this and i don't know
// what it's talking about
() => Validator.#validate(...arguments)
);
default:
console.error(await response.json());
return {
code: Validator.Code.ERROR,
message: `USPS returned status code ${response.status}`,
correctionCount: 0,
address: null,
};
}
}
/**
* @param {any} json
* @param {QueriedAddress} query
* @param {string[]} zipParts
* @returns {ValResult}
*/
static #makeCorrectionResult(json, query, zipParts) {
let note = "";
let correctionCount = 0;
const code = json.corrections[0]?.code || json.matches[0]?.code;
switch (code) {
case "31":
break;
case "32":
note = (
"Missing or incorrectly-formatted apartment, suite, or "
+ "box number."
);
correctionCount++;
break;
case "22":
note = json.corrections[0].text;
correctionCount++;
break;
default:
return {
code: Validator.Code.NOT_IMPL,
message: `Status code ${code} not implemented`,
correctionCount: 0,
address: null,
};
}
/**
* @type {CanonicalAddress}
*/
const canon = {
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 (canon.streetAddress === query.streetAddress) {
new_addr += query.streetAddress;
} else {
new_addr += `<strong>${canon.streetAddress}</strong>`;
correctionCount++;
}
new_addr += "<br>";
if (canon.city === query.city) {
new_addr += query.city;
} else {
new_addr += `<strong>${canon.city}</strong>`;
correctionCount++;
}
new_addr += ", ";
if (canon.state === query.state) {
new_addr += query.state;
} else {
new_addr += `<strong>${canon.state}</strong>`;
correctionCount++;
}
new_addr += " ";
if (canon.zip5 === zipParts[0] && canon.zip4 === zipParts[1]) {
new_addr += `${canon.zip5}-${canon.zip4}`;
} else {
new_addr += `<strong>${canon.zip5}-${canon.zip4}</strong>`;
correctionCount++;
}
if (correctionCount > 0) {
return {
code: Validator.Code.CORRECTION,
message: `<span>${new_addr}${note ? `<br><i>${note}</i>` : ""}</span>`,
correctionCount: correctionCount,
address: canon,
};
} else {
return {
code: Validator.Code.MATCH,
message: "",
correctionCount: 0,
address: canon,
};
}
}
/**
* Do some trivial checks that we don't need the API for ourselves.
* @param {QueriedAddress} address
* @returns {ValResult?}
*/
static #trivialChecks({ streetAddress, city, state, zip, country }) {
// Missing required field
if (!streetAddress || !city || !state) {
return {
code: Validator.Code.EMPTY,
message: "",
correctionCount: 0,
address: null,
};
}
// Improper state abbreviation case
if (state !== state.toUpperCase()) {
const [zip4, zip5] = zip.split("-");
return {
code: Validator.Code.CORRECTION,
message: (
`${streetAddress.replace("\n", "<br>")}<br>${city}, `
+ `<strong>${state.toUpperCase()}</strong> ${zip}`
),
correctionCount: 1,
address: { streetAddress, city, state, zip4, zip5 },
};
}
// Non-US country code (except blank is fine)
if (country.length > 0 && country !== "US") {
return {
code: Validator.Code.NOPE,
message: "",
correctionCount: 0,
address: null,
};
}
return null;
}
/**
* @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.#CLIENT_ID,
client_secret: Validator.#CLIENT_SECRET,
}),
});
switch (response.status) {
case 200:
break;
case 429:
case 503:
return await Validator.#retry(Validator.#regenerateToken);
default:
throw Error(`${response.status} ${response.statusText}`);
}
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);
}
/**
* Exponential backoff retry
* @template T
* @param {() => Promise<T>} callback
* @returns {Promise<T>}
*/
static async #retry(callback) {
const timeoutDate = new Date();
timeoutDate.setMilliseconds(
timeoutDate.getMilliseconds() + Validator.#backoff
);
window.sessionStorage.setItem("ndk retry", timeoutDate.toISOString());
await delay(Validator.#backoff);
Validator.#backoff **= 2;
const promise = callback();
Validator.#backoff = Validator.#DEFAULT_BACKOFF;
window.sessionStorage.removeItem("ndk retry");
return await promise;
}
}
class Indicator {
#icon;
#typing;
/**
* @param {Node} parent
*/
constructor(parent) {
/**
* @type {ValResult}
*/
this.status = {
address: null,
code: Validator.Code.LOADING,
correctionCount: 0,
message: "",
};
this.#typing = false;
/**
* @type {HTMLButtonElement}
*/
this.button = document.createElement("button");
this.button.classList.add("jrc-address-validation-indicator");
this.button.setAttribute("data-toggle", "tooltip");
this.button.setAttribute("data-placement", "top");
this.button.setAttribute("data-html", "true");
this.button.type = "button";
this.button.disabled = true;
// @ts-ignore -- environment will have jquery
$(this.button).tooltip();
this.#setIcon("fal", "fa-spinner-third", "fa-spin");
this.#icon = document.createElement("i");
this.button.appendChild(this.#icon);
parent.appendChild(this.button);
}
/**
* @param {ValResult} result
* @returns {void}
*/
onValidationResult(result) {
if (this.#typing) return;
this.status = result;
const { code, message: msg, correctionCount: correctionCount } = this.status;
let tooltipContent = "";
this.#icon.classList.remove("fa-spinner-third", "fa-spin");
switch (code) {
case Validator.Code.LOADING:
this.#setIcon("fa-spinner-third", "fa-spin");
tooltipContent = "";
break;
case Validator.Code.MATCH:
this.#setIcon("fa-check");
tooltipContent = "USPS — Verified valid";
break;
case Validator.Code.CORRECTION:
this.#setIcon("fa-exclamation");
const s = correctionCount > 1 ? "s" : "";
tooltipContent = `USPS — Correction${s} suggested:<br>${msg}`;
this.button.disabled = false;
break;
case Validator.Code.NOT_FOUND:
this.#setIcon("fa-times");
tooltipContent = "USPS — Address not found";
break;
case Validator.Code.NOPE:
this.#setIcon("fa-circle");
tooltipContent = "USPS validation skipped: incompatible country";
break;
case Validator.Code.EMPTY:
this.#setIcon("fa-circle");
tooltipContent = "";
break;
case Validator.Code.ERROR:
this.#setIcon("fa-times");
tooltipContent = `ERROR: ${msg}. Contact Nate`;
break;
case Validator.Code.NOT_IMPL:
this.#setIcon("fa-times");
tooltipContent = `ERROR: ${msg}. Contact Nate`;
break;
default:
this.#setIcon("fa-times");
tooltipContent = "PLUGIN ERROR: contact Nate";
break;
}
this.button.setAttribute("data-original-title", tooltipContent);
}
/**
* Set an icon class while removing all the other icon classes.
* @param {...string} classNames
*/
#setIcon(...classNames) {
this.#icon.classList.remove(
"fa-spinner-third", "fa-spin",
"fa-check",
"fa-exclamation",
"fa-times",
"fa-circle",
);
this.#icon.classList.add(...classNames);
}
onTyping() {
this.#typing = true;
this.#setIcon("fa-spinner-third", "fa-spin");
this.button.removeAttribute("data-original-title");
this.button.disabled = true;
}
onEmptyField() {
this.#typing = false;
this.#setIcon("fa-circle");
}
}