Greasy Fork

Greasy Fork is available in English.

USPS Address Validation - Add/Edit Page

Integrate USPS address validation and autofill into the Address fields.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         USPS Address Validation - Add/Edit Page
// @namespace    https://github.com/nate-kean/
// @version      2026.01.08.2
// @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.icu/scripts/555040/1730304/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;
	// ASCII letters or the delete keys
	static #ALLOWED_KEYS = /^([\x20-\x7E])|(Backspace)|(Delete)$/;

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

	#panel;
	#heading;
	#validator;
	#indicator;
	/** @type {ReturnType<typeof setTimeout> | undefined} */
	#timeout = undefined;
	/** @type {Fields} */
	#fields;
	/** @type {HTMLSelectElement} */
	#flagSelect;
	/** @type {string[]} */
	#streetAddressLines = [];

	/**
	 * @param {string} id
	 * @param {string} panelSelector
	 * @param {string} headingSelector
	 */
	constructor(id, panelSelector, headingSelector) {
		this.#panel = tryQuerySelector(document, panelSelector);
		this.#heading = tryQuerySelector(document, headingSelector);
		this.#validator = new Validator();
		this.#indicator = new Indicator(this.#heading);
		this.#flagSelect = this.#getFlagSelect();

		// "Starts with" selectors because the IDs are all "id" and "id2"
		// between the two address panels
		this.#fields = {
			// Want to pass these as type parameters but casting is the best I
			// can do without converting this whole thing to .ts
			streetAddress:
				/** @type {HTMLTextAreaElement} */
				(tryQuerySelector(this.#panel, 'textarea[id^="address"')),
			city:
				/** @type {HTMLInputElement} */
				(tryQuerySelector(this.#panel, 'input[id^="city"')),
			state:
				/** @type {HTMLInputElement} */
				(tryQuerySelector(this.#panel, 'input[id^="state"')),
			zip:
				/** @type {HTMLInputElement} */
				(tryQuerySelector(this.#panel, 'input[id^="zipcode"')),
			country:
				/** @type {HTMLInputElement} */
				(tryQuerySelector(this.#panel, 'input[id^="country"')),
		};

		// Fill the address if the indicator is clicked
		this.#indicator.button.addEventListener(
			"click",
			this.#fillPanel.bind(this),
			{ passive: true },
		);
		// Listen for keyboard input in this controller's address panel
		// @ts-ignore -- My TypeScript doesn't know keyup is a KeyboardEvent???
		this.#panel.addEventListener(
			"keyup",
			this.#onKeypress.bind(this),
			{ passive: true },
		);
		// Re-process the address if the Address Validation field changes
		// This <select>'s jQuery brainworms make it fire through this instead
		// of the vanilla "change" event
		$(this.#flagSelect)
			.chosen()
			.on("change", this.#processQuery.bind(this));

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

	/**
	 * @returns {HTMLSelectElement}
	 */
	#getFlagSelect() {
		let select;
		for (const formGroup of document.querySelectorAll(".form-group")) {
			if (
				formGroup.querySelector("label")?.textContent.trim()
				!== "Address Validation"
			) {
				continue;
			}
			// Would pass HTMLSelectElement as a type parameter here if JSDoc
			// allowed it -- microsoft/TypeScript#27387
			select = tryQuerySelector(formGroup, "select");
			select.addEventListener("change", this.#processQuery.bind(this));
		}
		if (select === undefined) throw new Error("It's over");
		// @ts-ignore -- ignoring a type error caused by the above shortfall
		return select;
	}

	#fillPanel() {
		if (this.#indicator.status.code !== Validator.Code.CORRECTION) return;
		const address = this.#indicator.status.address;
		if (this.#flagSelect.value === "") {
			this.#fields.streetAddress.value = address.streetAddress;
		} else {
			this.#fields.streetAddress.value =
				`${this.#streetAddressLines[0]}\n${address.streetAddress}`;
		}
		this.#fields.city.value = address.city;
		this.#fields.state.value = address.state;
		this.#fields.zip.value = `${address.zip5}-${address.zip4}`
		// If USPS recognizes an address, we can safely assume the country is US
		this.#fields.country.value = "US";

		// Trigger F1 Primary address cascade
		this.#fields.country.dispatchEvent(new Event("input"));
		// Update the indicator
		this.#processQuery();
	}

	#processQuery() {
		const address = this.#getEnteredAddress();
		const heuristic = this.#doHeuristics(address);
		address.streetAddress = this.#normalizeStreetAddressQuery(
			address.streetAddress
		);
		this.#indicator.onTypingEnd();
		this.#validator.onNewAddressQuery(this.#indicator, address, heuristic);
	}

	/**
	 * @param {Readonly<KeyboardEvent>} evt
	 * @returns {Promise<void>}
	 */
	async #onKeypress(evt) {
		if (!AddEditPageController.#ALLOWED_KEYS.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.#processQuery.bind(this),
			AddEditPageController.#TYPING_WAIT_TIME_MS,
		);
	}

	/**
	 * @returns {QueriedAddress}
	 */
	#getEnteredAddress() {
		return {
			streetAddress: this.#fields.streetAddress.value,
			city:          this.#fields.city.value,
			state:         this.#fields.state.value,
			zip:           this.#fields.zip.value,
			country:       this.#fields.country.value,
		};
	}

	/**
	 * @param {string} streetAddress
	 * @returns {string[]}
	 */
	static #makeStreetAddrLines(streetAddress) {
		// Trim lines and remove blank lines.
		return streetAddress
			.split("\n")
			.map(line => line.trim())
			.filter(line => line.length > 0);
	}

	/**
	 * @param {string} streetAddress
	 * @returns {string}
	 */
	#normalizeStreetAddressQuery(streetAddress) {
		this.#streetAddressLines =
			AddEditPageController.#makeStreetAddrLines(streetAddress);
		if (this.#flagSelect.value === "") {
			return this.#streetAddressLines.join(" ");
		} else {
			return this.#streetAddressLines.toSpliced(0, 1).join(" ");
		}
	}

	/**
	 * @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");
		// Remove ?autofill-addr from the URL in your history so that this does
		// not trigger again if you go back to this page
		window.history.replaceState(null, "", url);
		this.#fillPanel();
	}

	/**
	 * @param {Readonly<QueriedAddress>} address
	 * @returns {ValResult?}
	 */
	#doHeuristics(address) {
		switch (this.#flagSelect.value) {
		case "":
			// There should not be an allcaps firstline
			if (AddEditPageController.#hasAllCapsFirstLine(address)) {
				return {
					code: Validator.Code.CHECK_FAILED,
					note: (
						"Address's first line is in all caps, but the "
						+ "Address Validation flag is not set."
					),
				};
			}
			return null;
		case "INVALID ADDRESS":
			// There should be an allcaps firstline
			// And beyond that we should not bother validating the address
			if (!AddEditPageController.#hasAllCapsFirstLine(address)) {
				return {
					code: Validator.Code.CHECK_FAILED,
					note: (
						"The Address Validation flag is set, but this "
						+ "address's first line isn't all caps."
					),
				};
			}
			return { code: Validator.Code.MARKED_INVALID };
		default:
			// There should be an allcaps firstline
			if (!AddEditPageController.#hasAllCapsFirstLine(address)) {
				return {
					code: Validator.Code.CHECK_FAILED,
					note: (
						"The Address Validation flag is set, but this "
						+ "address's first line isn't all caps."
					),
				};
			}
			return null;
		}
	}

	/**
	 * @param {Readonly<QueriedAddress>} address
	 * @returns {boolean}
	 */
	static #hasAllCapsFirstLine(address) {
		const lines = AddEditPageController.#makeStreetAddrLines(
			address.streetAddress,
		);
		return lines.length > 0 && lines[0] === lines[0].toUpperCase();
	}
}


(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",
	);
})();