Library used between the Add/Edit page and the View page.
بۇ قوليازمىنى بىۋاسىتە قاچىلاشقا بولمايدۇ. بۇ باشقا قوليازمىلارنىڭ ئىشلىتىشى ئۈچۈن تەمىنلەنگەن ئامبار بولۇپ، ئىشلىتىش ئۈچۈن مېتا كۆرسەتمىسىگە قىستۇرىدىغان كود: // @require https://update.greasyfork.org/scripts/555040/1700502/USPS%20Address%20Validation%20-%20Common.js
// ==UserScript==
// @name USPS Address Validation - Common
// @namespace https://github.com/nate-kean/
// @version 2025.11.23
// @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 {
background-color: unset !important;
& > .panel-title {
padding-right: 50px;
display: flex;
justify-content: space-between;
}
}
.address-panel > .panel-heading:has(.panel-title) {
width: 50% !important;
}
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: 260px !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
*/
/**
* @typedef {{
* code: typeof Validator.Code.MATCH;
* address: CanonicalAddress;
* }
* | {
* code: typeof Validator.Code.CORRECTION;
* addrHTML: string;
* note: string | null; // non-empty
* correctionCount: number; // non-zero
* address: CanonicalAddress;
* }
* | {
* code: typeof Validator.Code.WARNING;
* note: string; // non-empty
* }
* | { code: typeof Validator.Code.NOT_FOUND }
* | { code: typeof Validator.Code.NOPE }
* | {
* code: typeof Validator.Code.ERROR;
* note: string; // non-empty
* }
* | { code: typeof Validator.Code.LOADING }
* | { code: typeof Validator.Code.EMPTY }
* | {
* code: typeof Validator.Code.NOT_IMPL;
* note: string; // non-empty
* }
* | { code: typeof Validator.Code.NOT_FOUND }} ValResult
*/
/**
* @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 => {
return text.charAt(0).toUpperCase()
+ text.substring(1).toLowerCase();
},
)
.replace(/Po Box/i, "PO Box");
}
/**
* @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,
WARNING: 2,
NOT_FOUND: 3,
NOPE: 4,
ERROR: 5,
LOADING: 6,
EMPTY: 7,
NOT_IMPL: 8,
});
static #CLIENT_ID = "6mnicGgTpkmQ3gkf6Nr7Ati8NHhGc4tuGTwca3v4AsPGKIBL";
static #CLIENT_SECRET = "IUvAMfzOAAuDAn23yAylO1J9Y3MvE8AtDywW6SDPpvrazGmAvwOHLgJWs4Gkoy2w";
static #API_ROOT = "https://cors-proxy-mc6b.onrender.com/?url=https://apis.usps.com";
static #DEFAULT_BACKOFF = 4000;
static #backoff = Validator.#DEFAULT_BACKOFF;
/**
* @type {ValResult?}
*/
#lastResult;
constructor() {
this.#lastResult = null;
}
/**
* Call when there is a new address to validate.
* @param {Indicator} indicator
* @param {QueriedAddress} address
* @returns {Promise<void>}
*/
async onNewAddressQuery(indicator, address) {
console.trace({ address, lastResult: this.#lastResult });
if (
this.#lastResult !== null
&& this.#lastResult.code === Validator.Code.CORRECTION
&& this.#lastResult.address?.streetAddress === address.streetAddress
&& this.#lastResult.address?.city === address.city
&& this.#lastResult.address?.state === address.state
) {
const zipParts = address.zip.split("-");
if (
this.#lastResult.address.zip5 === zipParts[0]
&& this.#lastResult.address.zip4 === zipParts[1]
) {
// Last address matches the new queried one perfectly; we can
// skip asking USPS about it.
if (this.#lastResult.note === null) {
// USPS didn't have any other problems with it: it's a match!
console.debug(" ** Correction used; availed MATCH");
indicator.onValidationResult({
code: Validator.Code.MATCH,
address: this.#lastResult.address,
});
} else {
// USPS still left a note; include it.
console.debug(" ** Correction used; availed WARNING");
indicator.onValidationResult({
code: Validator.Code.WARNING,
note: this.#lastResult.note,
});
}
return;
}
}
// Check cache. Maybe this address has been checked in this browser
// before
const cached = Validator.#getFromCache(address);
if (cached !== null) {
console.debug(" ** Cache hit");
this.#lastResult = cached;
indicator.onValidationResult(cached);
return;
};
console.debug(" ** Cache miss");
const result = await Validator.#validate(address);
Validator.#sendToCache(address, result);
this.#lastResult = result;
indicator.onValidationResult(result);
}
/**
* @param {QueriedAddress} address
* @returns {Promise<ValResult>}
*/
static async #validate(address) {
console.trace(address);
let { streetAddress, city, state, zip } = address;
const csChecksResult = Validator.#clientSideChecks(address);
if (csChecksResult !== null) return csChecksResult;
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 so we
// normalize them to "apartment" instead (which is interpreted the
// same but is never going to be in a canonical address).
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, address);
if (earlyResult !== null) return earlyResult;
const json = await response.json();
console.debug(json);
return Validator.#makeCorrectionResult(json, address, zipParts);
}
static async #pickUpTimeout() {
// Handle being timed out on a previous page
const prevBackoffDateStr = window.sessionStorage.getItem("ndk retry");
if (prevBackoffDateStr === null) return;
console.trace(` ** Backoff: ${prevBackoffDateStr}`);
const prevBackoffDate = new Date(prevBackoffDateStr);
const prelimBackoffMS = (
prevBackoffDate.getMilliseconds()
- (new Date().getMilliseconds())
);
await delay(prelimBackoffMS);
window.sessionStorage.removeItem("ndk retry");
}
/**
* @param {Response} response
* @param {QueriedAddress} address
* @returns {Promise<ValResult?>}
*/
static async #parseStatus(response, address) {
console.debug(` ** HTTP ${response.status}`);
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 };
default:
console.error(json);
return {
code: Validator.Code.ERROR,
note: "USPS returned error True 400",
};
}
}
case 401:
await Validator.#regenerateToken();
return await Validator.#validate(address);
case 404:
return { code: Validator.Code.NOT_FOUND };
case 429:
return {
code: Validator.Code.ERROR,
note: "Rate limit exceeded. Please wait and try again.",
};
case 503:
return await Validator.#retry(
() => Validator.#validate(address)
);
default:
console.error(await response.json());
return {
code: Validator.Code.ERROR,
note: `USPS returned status code ${response.status}`,
};
}
}
/**
* @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,
note: `Status code ${code} not implemented`,
};
}
/**
* @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 addrHTML = "";
if (canon.streetAddress === query.streetAddress) {
addrHTML += query.streetAddress;
} else {
addrHTML += `<strong>${canon.streetAddress}</strong>`;
correctionCount++;
}
addrHTML += "<br>";
if (canon.city === query.city) {
addrHTML += query.city;
} else {
addrHTML += `<strong>${canon.city}</strong>`;
correctionCount++;
}
addrHTML += ", ";
if (canon.state === query.state) {
addrHTML += query.state;
} else {
addrHTML += `<strong>${canon.state}</strong>`;
correctionCount++;
}
addrHTML += " ";
if (canon.zip5 === zipParts[0] && canon.zip4 === zipParts[1]) {
addrHTML += `${canon.zip5}-${canon.zip4}`;
} else {
addrHTML += `<strong>${canon.zip5}-${canon.zip4}</strong>`;
correctionCount++;
}
switch (correctionCount) {
case 0:
return { code: Validator.Code.MATCH, address: canon};
case 1:
if (code === "32") {
return { code: Validator.Code.WARNING, note };
}
// [explicit fallthrough]
default:
return {
code: Validator.Code.CORRECTION,
addrHTML,
note: note || null, // Please no empty strings here
correctionCount: correctionCount,
address: canon,
};
}
}
/**
* Do some trivial checks that we don't need the API for clientside.
* @param {QueriedAddress} address
* @returns {ValResult?}
*/
static #clientSideChecks({ streetAddress, city, state, zip, country }) {
// Missing required field
if (!streetAddress || !city || !state) {
return { code: Validator.Code.EMPTY };
}
// Improper state abbreviation case
if (state !== state.toUpperCase()) {
const [zip4, zip5] = zip.split("-");
return {
code: Validator.Code.CORRECTION,
addrHTML: (
`${streetAddress.replace("\n", "<br>")}<br>${city}, `
+ `<strong>${state.toUpperCase()}</strong> ${zip}`
),
note: null,
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 };
}
return null;
}
/**
* @returns {Promise<string | null>}
*/
static async #getAccessToken() {
console.trace();
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() {
console.trace();
const response = await fetch(
`${Validator.#API_ROOT}/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();
console.debug(json);
window.localStorage.setItem("natesUSPSAccessToken", json.access_token);
}
/**
* @param {QueriedAddress} address
* @returns {string}
*/
static #serializeAddress(address) {
let result = "ndk ";
for (const value of Object.values(address)) {
result += `${value || "[blank]"} `;
}
return result;
}
/**
* @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;
console.debug(Validator.#backoff);
const promise = callback();
Validator.#backoff = Validator.#DEFAULT_BACKOFF;
window.sessionStorage.removeItem("ndk retry");
return await promise;
}
}
class Indicator {
#icon;
#buttonJQ;
#isOnTypingCooldown;
/**
* @param {Node} parent
*/
constructor(parent) {
console.trace(parent);
/**
* @type {ValResult}
*/
this.status = { code: Validator.Code.LOADING };
this.#isOnTypingCooldown = 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.#buttonJQ = $(this.button);
this.#buttonJQ.tooltip();
this.#icon = document.createElement("i");
this.#setIcon("fal", "fa-spinner-third", "fa-spin");
this.button.appendChild(this.#icon);
parent.appendChild(this.button);
}
/**
* @param {ValResult} result
* @returns {void}
*/
onValidationResult(result) {
console.trace();
// The user had kept typing, so this result is now stale and should not
// be displayed!
if (this.#isOnTypingCooldown) return;
let tooltipContent = "";
this.#icon.classList.remove("fa-spinner-third", "fa-spin");
this.button.disabled = true;
switch (result.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 = result.correctionCount > 1 ? "s" : "";
tooltipContent = (
`USPS — Correction${s} suggested:<br>`
+ `${result.addrHTML}`
);
if (result.note !== null) tooltipContent += `<br>${result.note}`;
this.button.disabled = false;
break;
}
case Validator.Code.WARNING:
this.#setIcon("fa-exclamation");
tooltipContent = `USPS — Warning:<br>${result.note}`;
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: ${result.note}. Contact Nate`;
break;
case Validator.Code.NOT_IMPL:
this.#setIcon("fa-times");
tooltipContent = `ERROR: ${result.note}. Contact Nate`;
break;
default:
this.#setIcon("fa-times");
tooltipContent = "PLUGIN ERROR: contact Nate";
break;
}
this.button.setAttribute("data-original-title", tooltipContent);
this.status = result;
}
/**
* Set an icon class while removing all the other icon classes.
* @param {...string} classNames
*/
#setIcon(...classNames) {
console.debug(classNames);
this.#icon.classList.remove(
"fa-spinner-third", "fa-spin",
"fa-check",
"fa-exclamation",
"fa-times",
"fa-circle",
);
this.#icon.classList.add(...classNames);
}
onTypingStart() {
console.trace();
this.#isOnTypingCooldown = true;
this.#setIcon("fa-spinner-third", "fa-spin");
this.button.removeAttribute("data-original-title");
this.button.disabled = true;
}
onTypingEnd() {
console.trace();
this.#isOnTypingCooldown = false;
}
onEmptyField() {
console.trace();
this.#isOnTypingCooldown = false;
this.#setIcon("fa-circle");
}
}