USPS Address Validation - Add/Edit Page

Integrate USPS address validation and autofill into the Address fields.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         USPS Address Validation - Add/Edit Page
// @namespace    https://github.com/nate-kean/
// @version      2025.12.3
// @description  Integrate USPS address validation and autofill into the Address fields.
// @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
// @require      https://update.greasyfork.org/scripts/555040/1707051/USPS%20Address%20Validation%20-%20Common.js
// ==/UserScript==

// @ts-check

/**
 * Entry point for the program.
 * Holds the Add/Edit-page-specific logic for capturing addresses.
 */

/**
 * @typedef {Object} Fields
 * @property {HTMLTextAreaElement} streetAddress
 * @property {HTMLInputElement} city
 * @property {HTMLInputElement} state
 * @property {HTMLInputElement} zip
 * @property {HTMLInputElement} country
 */


class AddEditPageController {
	static #TYPING_WAIT_TIME_MS = 1_000;
	static #ASCII_PATTERN = /[0-9A-Za-z]/;

	/**
	 * @type {(keyof Fields)[]}
	 */
	static #REQUIRED_FIELD_NAMES = ["streetAddress", "city", "state"];

	#panel;
	#heading;
	#validator;
	#indicator;
	#timeout;
	#fields;

	/**
	 * @param {string} id
	 * @param {string} panelSelector
	 * @param {string} headingSelector
	 */
	constructor(id, panelSelector, headingSelector) {
		console.trace(id, panelSelector, headingSelector);
		this.#panel = tryQuerySelector(document, panelSelector);
		this.#heading = tryQuerySelector(document, headingSelector);
		this.#validator = new Validator();
		this.#indicator = new Indicator(this.#heading);
		this.#timeout = 0;
		this.#indicator.button.addEventListener(
			"click",
			this.#fillPanel.bind(this),
			{ passive: true },
		);
		this.#panel.addEventListener(
			"keyup",
			this.#onKeypress.bind(this),
			{ passive: true },
		);

		// "Starts with" selectors because the IDs are "id" and "id2" between
		// the two address panels
		/**
		 * @type {Fields}
		 */
		this.#fields = {
			streetAddress: tryQuerySelector(this.#panel, 'textarea[id^="address"'),
			city: tryQuerySelector(this.#panel, 'input[id^="city"'),
			state: tryQuerySelector(this.#panel, 'input[id^="state"'),
			zip: tryQuerySelector(this.#panel, 'input[id^="zipcode"'),
			country: tryQuerySelector(this.#panel, 'input[id^="country"'),
		};

		this.#sendQuery();
		this.#tryAutofill(id);
	}

	#fillPanel() {
		console.trace(this.#indicator.status.code);
		if (this.#indicator.status.code !== Validator.Code.CORRECTION) return;
		const address = this.#indicator.status.address;
		for (const key in address) {
			if (key === "zip5") {
				this.#fields.zip.value = `${address.zip5}-${address.zip4}`;
				continue;
			};
			if (key === "zip4") continue;
			// @ts-ignore -- types would fix this but it would be complicated
			// and i don't want to do it in jsdoc
			this.#fields[key].value = address[key];
		}
		// If USPS recognizes an address, country is US
		this.#fields.country.value = "US";
		// Trigger F1 Primary address cascade
		this.#fields.country.dispatchEvent(new Event("input"));
		// Update the indicator
		this.#sendQuery();
	}

	#sendQuery() {
		const address = this.#getEnteredAddress();
		console.trace(address);
		this.#validator.onNewAddressQuery(this.#indicator, address);
		this.#indicator.onTypingEnd();
	}

	/**
	 * @param {KeyboardEvent} evt
	 * @returns {Promise<void>}
	 */
	async #onKeypress(evt) {
		console.trace(evt.key);
		if (!AddEditPageController.#ASCII_PATTERN.test(evt.key)) return;
		console.debug(" ** Cooldown reset");
		clearTimeout(this.#timeout);
		this.#indicator.onTypingStart();
		for (const fieldName of AddEditPageController.#REQUIRED_FIELD_NAMES) {
			if (this.#fields[fieldName].value.trim().length === 0) {
				this.#indicator.onEmptyField();
				return;
			}
		}
		this.#timeout = setTimeout(
			this.#sendQuery.bind(this),
			AddEditPageController.#TYPING_WAIT_TIME_MS,
		);
	}

	/**
	 * @returns {QueriedAddress}
	 */
	#getEnteredAddress() {
		/**
		 * @type {QueriedAddress}
		 */
		const address = {};
		for (const key in this.#fields) {
			if (key == "streetAddress") {
				// @ts-ignore -- no the field definitely isnt null atp.
				// and value exists on all these types of elements too
				const lines = this.#fields.streetAddress.value.split("\n");
				address.streetAddress = AddEditPageController.normalizeStreetAddressQuery(lines);
				continue;
			}
			// @ts-ignore
			address[key] = this.#fields[key].value;
		}
		// @ts-ignore
		return address;
	}

	/**
	 * @param {string[]} streetAddrLines
	 * @returns {string}
	 */
	static normalizeStreetAddressQuery(streetAddrLines) {
		// If the individual has an Address Validation flag, ignore the first
		// line of the street address, because it's probably a message about the
		// address.
		const otherInfoFormGroups = document.querySelectorAll(
			".additional-info-panel .form-group"
		);
		let iStartStreetAddr = 0;
		if (streetAddrLines.length > 1) {
			for (const formGroup of otherInfoFormGroups) {
				const label = formGroup.querySelector("label");
				if (label?.textContent.trim() !== "Address Validation") continue;
				const select = formGroup.querySelector("select");
				// This one is actually fine if it's set to None or not selected
				if (select === null || select.selectedIndex <= 1) continue;
				// Skip first line in the text box (the addr validation comment)
				iStartStreetAddr = 1;
				break;
			}
		}
		// Construct the street address, ignoring beginning lines if the above
		// block says to, and using spaces instead of <br />s or newlines.
		let streetAddress = "";
		for (let i = iStartStreetAddr; i < streetAddrLines.length; i++) {
			const text = streetAddrLines[i];
			streetAddress += text.trim();
			if (i + 1 !== streetAddrLines.length) {
				streetAddress += " ";
			}
		}
		return streetAddress;
	}

	/**
	 * @param {string} id
	 */
	#tryAutofill(id) {
		const url = new URL(window.location.href);
		const request = url.searchParams.get("autofill-addr");
		if (request === null) return;
		if (request !== id) return;
		url.searchParams.delete("autofill-addr");
		window.history.replaceState(null, "", url);
		this.#fillPanel();
	}
}


(async () => {
	console.log("USPS Address Validator");
	new AddEditPageController(
		"1",
		".address-left-col",
		".address-panel > .panel-heading > .panel-title",
	);
	new AddEditPageController(
		"2",
		".address-right-col",
		".address-2-header",
	);
})();