Form Faker

Fill any form with realistic fake data pulled from US Census name lists. Smart field detection, framework compat, persistent cache with background refresh.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

Advertisement:

// ==UserScript==
// @name         Form Faker
// @namespace    https://minoa.cat
// @version      1.4.0
// @description  Fill any form with realistic fake data pulled from US Census name lists. Smart field detection, framework compat, persistent cache with background refresh.
// @author       Minoa
// @homepage     https://minoa.cat
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      raw.githubusercontent.com
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ── config ───────────────────────────────────────────────────────────────────

  const NAME_URLS = {
    last:   'https://raw.githubusercontent.com/treyhunner/names/refs/heads/master/names/dist.all.last',
    male:   'https://raw.githubusercontent.com/treyhunner/names/refs/heads/master/names/dist.male.first',
    female: 'https://raw.githubusercontent.com/treyhunner/names/refs/heads/master/names/dist.female.first',
  };

  const STORAGE_KEY   = 'ff_name_cache_v1';
  const CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days before silent background refresh

  // ── static data ──────────────────────────────────────────────────────────────

  const CITIES = [
    { city: 'Austin',           state: 'Texas',          zip: '78701' },
    { city: 'Denver',           state: 'Colorado',        zip: '80203' },
    { city: 'Atlanta',          state: 'Georgia',         zip: '30303' },
    { city: 'Phoenix',          state: 'Arizona',         zip: '85003' },
    { city: 'Portland',         state: 'Oregon',          zip: '97201' },
    { city: 'Nashville',        state: 'Tennessee',       zip: '37203' },
    { city: 'Charlotte',        state: 'North Carolina',  zip: '28202' },
    { city: 'Indianapolis',     state: 'Indiana',         zip: '46204' },
    { city: 'Columbus',         state: 'Ohio',            zip: '43215' },
    { city: 'Jacksonville',     state: 'Florida',         zip: '32202' },
    { city: 'Memphis',          state: 'Tennessee',       zip: '38103' },
    { city: 'Louisville',       state: 'Kentucky',        zip: '40202' },
    { city: 'Baltimore',        state: 'Maryland',        zip: '21201' },
    { city: 'Milwaukee',        state: 'Wisconsin',       zip: '53202' },
    { city: 'Albuquerque',      state: 'New Mexico',      zip: '87102' },
    { city: 'Tucson',           state: 'Arizona',         zip: '85701' },
    { city: 'Sacramento',       state: 'California',      zip: '95814' },
    { city: 'Kansas City',      state: 'Missouri',        zip: '64101' },
    { city: 'Omaha',            state: 'Nebraska',        zip: '68102' },
    { city: 'Raleigh',          state: 'North Carolina',  zip: '27601' },
    { city: 'Colorado Springs', state: 'Colorado',        zip: '80903' },
    { city: 'Virginia Beach',   state: 'Virginia',        zip: '23451' },
    { city: 'Boise',            state: 'Idaho',           zip: '83701' },
    { city: 'Richmond',         state: 'Virginia',        zip: '23219' },
    { city: 'Spokane',          state: 'Washington',      zip: '99201' },
    { city: 'Tulsa',            state: 'Oklahoma',        zip: '74103' },
    { city: 'Tampa',            state: 'Florida',         zip: '33601' },
    { city: 'St. Louis',        state: 'Missouri',        zip: '63101' },
    { city: 'Pittsburgh',       state: 'Pennsylvania',    zip: '15201' },
    { city: 'Salt Lake City',   state: 'Utah',            zip: '84101' },
  ];

  const STREETS    = ['Oak','Maple','Cedar','Pine','Elm','Washington','Park','Lake','Hillside',
                      'Riverside','Sunset','Valley','Forest','Meadow','Spring','Church','Main',
                      'Lincoln','Jefferson','Highland','Willow','Birch','Ash','Poplar','Walnut',
                      'Lakeview','Ridge','Orchard','Clearwater'];
  const STREET_SFX = ['St','Ave','Blvd','Dr','Ln','Ct','Pl','Way','Rd','Cir','Terrace','Trail','Loop','Pass'];
  const CO_ADJ     = ['Global','Advanced','Premier','Apex','Summit','Nexus','Dynamic','Precision',
                      'Unified','Streamline','Quantum','Vanguard','Core','Peak','Delta','Horizon',
                      'Catalyst','Integral','Meridian','Axiom'];
  const CO_NOUN    = ['Solutions','Systems','Technologies','Consulting','Services','Industries',
                      'Partners','Group','Ventures','Associates','Innovations','Networks',
                      'Dynamics','Analytics','Strategies'];
  const CO_SFX     = ['Inc.','LLC','Co.','Corp.','Ltd.','& Associates','International'];
  const EMAIL_DOM  = ['gmail.com','yahoo.com','outlook.com','hotmail.com','icloud.com',
                      'protonmail.com','mail.com','live.com','me.com','fastmail.com'];
  const MONTHS     = ['January','February','March','April','May','June',
                      'July','August','September','October','November','December'];

  // ── session state ────────────────────────────────────────────────────────────

  let nameCache      = null;   // { last, male, female } in-memory for this page load
  let sessionProfile = null;   // reused within the same page load unless reset
  let fetchPromise   = null;   // in-flight fetch promise; shared by all concurrent callers

  // ── helpers ──────────────────────────────────────────────────────────────────

  const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
  const pick = arr => arr[rand(0, arr.length - 1)];

  function parseNameFile(text) {
    const out = [];
    for (const line of text.trim().split('\n')) {
      const tok = line.trim().split(/\s+/)[0];
      if (tok && /^[A-Z']+$/.test(tok))
        out.push(tok.charAt(0).toUpperCase() + tok.slice(1).toLowerCase());
    }
    return out;
  }

  // GM_xmlhttpRequest runs at extension privilege level and is never subject to
  // same-origin / CORS restrictions — the browser does not apply those checks
  // to extension-level requests.
  function fetchText(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET', url,
        onload:    r => r.status === 200 ? resolve(r.responseText) : reject(new Error(`HTTP ${r.status}`)),
        onerror:   () => reject(new Error('network error')),
        ontimeout: () => reject(new Error('timeout')),
        timeout: 20000,
      });
    });
  }

  // ── persistent storage ───────────────────────────────────────────────────────

  function loadStored() {
    try {
      const raw = GM_getValue(STORAGE_KEY, null);
      if (!raw) return null;
      const p = JSON.parse(raw);
      if (p && Array.isArray(p.last)   && p.last.length &&
               Array.isArray(p.male)   && p.male.length &&
               Array.isArray(p.female) && p.female.length) return p;
    } catch (_) {}
    return null;
  }

  function saveToStorage(data) {
    try { GM_setValue(STORAGE_KEY, JSON.stringify({ ...data, ts: Date.now() })); } catch (_) {}
  }

  function cacheAge() {
    try {
      const raw = GM_getValue(STORAGE_KEY, null);
      if (!raw) return Infinity;
      const p = JSON.parse(raw);
      return p && p.ts ? Date.now() - p.ts : Infinity;
    } catch (_) { return Infinity; }
  }

  // ── fetch pipeline ───────────────────────────────────────────────────────────

  /**
   * Fire the network fetch, or return the already-in-flight promise if one
   * exists. Using a shared promise (rather than a boolean flag) means a
   * second caller that arrives while the first fetch is still resolving
   * awaits the *same* result instead of getting an early no-op return — the
   * previous boolean-flag version silently did nothing for the second caller
   * even though nameCache was still empty.
   */
  function fetchAndStore(silent = false) {
    if (fetchPromise) return fetchPromise;

    fetchPromise = (async () => {
      if (!silent) toastLeft('fetching name lists...');
      try {
        const [lastTxt, maleTxt, femTxt] = await Promise.all([
          fetchText(NAME_URLS.last),
          fetchText(NAME_URLS.male),
          fetchText(NAME_URLS.female),
        ]);
        const fresh = {
          last:   parseNameFile(lastTxt),
          male:   parseNameFile(maleTxt),
          female: parseNameFile(femTxt),
        };
        nameCache = fresh;
        saveToStorage(fresh);
        if (!silent) toastLeft('name lists cached', 2500);
        else         toastLeft('name lists refreshed', 2500);
      } catch (err) {
        if (!silent) toastLeft(`fetch failed: ${err.message}`, 5000);
        else         console.warn('[FormFaker] background refresh failed:', err.message);
        throw err; // let awaiting callers see the failure
      } finally {
        fetchPromise = null;
      }
    })();

    return fetchPromise;
  }

  /**
   * Ensure nameCache is populated. Strategy (stale-while-revalidate):
   *
   *   1. already in memory             → return immediately (no I/O at all)
   *   2. stored cache present + fresh  → load instantly, skip network
   *   3. stored cache present + stale  → load instantly (fast fill), then
   *                                       silently refresh in background
   *   4. no stored cache               → block on fetch, show left toast
   *
   * fill() is called right after this resolves so cases 1-3 are always instant.
   * If two calls race in case 4, both now await the same fetchPromise instead
   * of the second one falling through with an empty nameCache.
   */
  async function ensureNames() {
    if (nameCache) return;

    const stored = loadStored();
    if (stored) {
      nameCache = stored;
      if (cacheAge() > CACHE_MAX_AGE) fetchAndStore(true); // not awaited — fire and forget
      return;
    }

    // first ever run — must block, and any concurrent caller awaits the same promise
    await fetchAndStore(false);
  }

  // ── profile generation ───────────────────────────────────────────────────────

  function makeProfile() {
    const gender    = Math.random() < 0.5 ? 'male' : 'female';
    const firstName = pick(nameCache[gender]);
    const lastName  = pick(nameCache.last);
    const loc       = pick(CITIES);
    const fn        = firstName.toLowerCase();
    const ln        = lastName.toLowerCase();

    const emailUser = pick([
      `${fn}.${ln}`,
      `${fn}${ln}`,
      `${fn[0]}${ln}`,
      `${fn}${rand(10, 99)}`,
      `${fn}.${ln.slice(0, rand(2, 4))}${rand(1, 9)}`,
    ]);
    const email = `${emailUser}@${pick(EMAIL_DOM)}`;

    const dobAge   = rand(18, 65);
    const dobYear  = new Date().getFullYear() - dobAge;
    const dobMonth = rand(1, 12);
    const dobDay   = rand(1, 28);
    const dobMM    = String(dobMonth).padStart(2, '0');
    const dobDD    = String(dobDay).padStart(2, '0');
    const dobYYYY  = String(dobYear);

    return {
      firstName, lastName,
      fullName:     `${firstName} ${lastName}`,
      email,
      emailConfirm: email,
      phone:        `(${rand(200, 999)}) ${rand(200, 999)}-${String(rand(1000, 9999))}`,
      company:      `${pick(CO_ADJ)} ${pick(CO_NOUN)} ${pick(CO_SFX)}`,
      username:     `${fn}${ln.slice(0, rand(2, 4))}${rand(10, 99)}`,
      street:       `${rand(100, 9999)} ${pick(STREETS)} ${pick(STREET_SFX)}`,
      address2:     Math.random() < 0.25 ? `Apt ${rand(1, 999)}` : '',
      city:         loc.city,
      state:        loc.state,
      zip:          loc.zip,
      country:      'United States',
      dob:          `${dobMM}/${dobDD}/${dobYear}`,
      dobIso:       `${dobYYYY}-${dobMM}-${dobDD}`,
      dobMonth:     dobMM,
      dobDay:       dobDD,
      dobYear:      dobYYYY,
      dobMonthName: MONTHS[dobMonth - 1],
      age:          String(dobAge),
      gender,
      url:          `https://${fn}${ln.slice(0, 4)}.example.com`,
    };
  }

  // ── field type detection ─────────────────────────────────────────────────────

  const SKIP_TYPES = new Set([
    'hidden','submit','button','reset','image','file',
    'checkbox','radio','range','color','password','search',
  ]);

  function getLabelText(el) {
    if (el.id) {
      try {
        const lbl = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
        if (lbl) return lbl.innerText || lbl.textContent || '';
      } catch (_) {}
    }
    const wrap = el.closest('label');
    if (wrap) return wrap.innerText || wrap.textContent || '';
    const lid = el.getAttribute('aria-labelledby');
    if (lid) {
      const lbl = document.getElementById(lid);
      if (lbl) return lbl.innerText || lbl.textContent || '';
    }
    return '';
  }

  function detectFieldType(el) {
    const tag  = el.tagName.toLowerCase();
    const type = (el.type || 'text').toLowerCase();

    if (tag === 'input' && SKIP_TYPES.has(type)) return 'skip';

    const ac = (el.getAttribute('autocomplete') || '').toLowerCase().trim();

    const rawHints = [
      ac,
      el.name || '', el.id || '',
      el.placeholder || '',
      el.getAttribute('aria-label') || '',
      getLabelText(el),
      el.getAttribute('ng-model') || '',
      el.getAttribute('data-ng-model') || '',
      el.getAttribute('v-model') || '',
      el.getAttribute('wire:model') || '',
      el.getAttribute('formcontrolname') || '',
      el.className || '',
    ].join(' ');
    const hints = rawHints.toLowerCase().replace(/[-_]/g, ' ');

    // NOTE: street-address (single-field, spec-intended for a full address,
    // sometimes a <textarea>) and address-line1 (first of a split address)
    // are distinct per the WHATWG autofill spec but are coalesced here since
    // real-world forms essentially never use both patterns on the same page.
    const AC_MAP = {
      'given-name': 'firstName', 'additional-name': 'firstName',
      'family-name': 'lastName',
      'name': 'fullName',
      'email': 'email',
      'tel': 'phone', 'tel-national': 'phone', 'tel-local': 'phone', 'tel-area-code': 'phone',
      'organization': 'company',
      'street-address': 'street', 'address-line1': 'street',
      'address-line2': 'address2',
      'address-level2': 'city',
      'address-level1': 'state',
      'postal-code': 'zip',
      'country': 'country', 'country-name': 'country',
      'username': 'username', 'nickname': 'username',
      'bday': 'dob',
      'bday-day': 'dobDay', 'bday-month': 'dobMonth', 'bday-year': 'dobYear',
      'url': 'url',
      'sex': 'gender',
    };
    if (AC_MAP[ac]) return AC_MAP[ac];

    if (type === 'email') return /confirm|verify|re.?enter|repeat|again|second|retype/.test(hints) ? 'emailConfirm' : 'email';
    if (type === 'tel')   return 'phone';
    if (type === 'date')  return 'dob';
    if (type === 'url')   return 'url';
    if (type === 'number' && /\bage\b/.test(hints)) return 'age';

    const PATTERNS = [
      [/\bfirst.?name\b|\bfname\b|\bgiven.?name\b|\bforename\b/,                     'firstName'],
      [/\blast.?name\b|\blname\b|\bsurname\b|\bfamily.?name\b/,                      'lastName'],
      [/\bfull.?name\b|\byour.?name\b|\bdisplay.?name\b|\breal.?name\b/,             'fullName'],
      [/confirm.?email|email.?confirm|re.?enter.?email|verify.?email|retype.?email/, 'emailConfirm'],
      [/\bemail\b/,                                                                    'email'],
      [/\bphone\b|\btel\b|\bmobile\b|\bcell.?phone\b/,                                'phone'],
      [/\bcompany\b|\borganization\b|\borganisation\b|\bemployer\b/,                  'company'],
      [/address.?2|addr.?2|\bapt\b|\bsuite\b|\bunit\b|\bline.?2\b/,                  'address2'],
      [/\bstreet\b|address.?1|addr.?1|\baddress\b|\baddr\b/,                          'street'],
      [/\bcity\b|\btown\b|\blocality\b/,                                               'city'],
      [/\bstate\b|\bprovince\b|\bregion\b/,                                           'state'],
      [/\bzip\b|\bpostal\b|\bpostcode\b|\bzip.?code\b/,                               'zip'],
      [/\bcountry\b/,                                                                  'country'],
      [/\busername\b|\buser.?name\b|\bhandle\b|\bscreen.?name\b/,                     'username'],
      [/\bdob\b|\bbirth.?date\b|\bdate.?of.?birth\b|\bbirthday\b/,                    'dob'],
      [/\bbirth.?month\b|\bmonth.?of.?birth\b/,                                      'dobMonth'],
      [/\bbirth.?day\b|\bday.?of.?birth\b/,                                           'dobDay'],
      [/\bbirth.?year\b|\byear.?of.?birth\b/,                                         'dobYear'],
      [/\bage\b/,                                                                      'age'],
      [/\bgender\b|\bsex\b/,                                                          'gender'],
      [/\bwebsite\b|\burl\b|\bhomepage\b/,                                            'url'],
    ];

    for (const [pat, ft] of PATTERNS) if (pat.test(hints)) return ft;
    return 'unknown';
  }

  // ── framework compat ─────────────────────────────────────────────────────────

  function setInputValue(el, value) {
    if (el.disabled || el.readOnly) return false;
    const str = String(value);
    try {
      const desc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value');
      if (desc && desc.set) desc.set.call(el, str);
      else el.value = str;
    } catch (_) { el.value = str; }
    for (const evtName of ['input', 'change', 'blur'])
      el.dispatchEvent(new Event(evtName, { bubbles: true, cancelable: true }));
    // AngularJS's ngModel directive listens for the native 'input' event above
    // and normally calls $setViewValue itself in response — this manual call
    // is defense-in-depth for older/custom directive setups where that native
    // listener path isn't wired up, not strictly required in the common case.
    try {
      if (window.angular) {
        const $el   = window.angular.element(el);
        const mdl   = $el.controller('ngModel');
        const scope = $el.scope() || $el.isolateScope();
        if (mdl && scope) scope.$apply(() => mdl.$setViewValue(str));
      }
    } catch (_) {}
    return true;
  }

  function setSelectValue(sel, value, altValues = []) {
    if (sel.disabled) return false;
    const opts = Array.from(sel.options);
    const find = needle => {
      needle = String(needle).toLowerCase();
      return opts.find(o => o.text.toLowerCase() === needle) ||
             opts.find(o => o.text.toLowerCase().startsWith(needle)) ||
             opts.find(o => o.value.toLowerCase() === needle) ||
             opts.find(o => o.text.toLowerCase().includes(needle));
    };
    let opt = find(value);
    if (!opt) for (const alt of altValues) { opt = find(alt); if (opt) break; }
    if (!opt) return false;
    sel.value = opt.value;
    sel.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
    try {
      if (window.angular) {
        const scope = window.angular.element(sel).scope();
        if (scope) scope.$apply();
      }
    } catch (_) {}
    return true;
  }

  // ── visual feedback ───────────────────────────────────────────────────────────

  function flash(el) {
    const was = { outline: el.style.outline, transition: el.style.transition, outlineOffset: el.style.outlineOffset };
    el.style.transition    = 'outline 0.1s ease, outline-offset 0.1s ease';
    el.style.outline       = '2px solid #22c55e';
    el.style.outlineOffset = '1px';
    setTimeout(() => Object.assign(el.style, was), 900);
  }

  // ── toast system ──────────────────────────────────────────────────────────────
  //
  //   toastLeft  (bottom-left)  — fetch / cache status
  //   toastRight (top-right)    — fill results
  //
  // Each toast host is a Shadow DOM root, not a plain div. A plain div's
  // inline styles usually win the specificity fight against page CSS, but not
  // always: an ancestor with `transform`, `filter`, `perspective`, or
  // `contain: layout` on <body> creates a new containing block, which makes
  // `position: fixed` resolve relative to that ancestor instead of the
  // viewport — silently breaking placement on sites that use those properties
  // for unrelated reasons. A closed styling boundary via attachShadow (mode:
  // 'open' so we can still update it, but shadow *style* encapsulation is
  // unconditional regardless of open/closed) sidesteps this: page rules can't
  // reach in, and our styles can't leak out either.

  const _toasts = {};

  function showToast(side, msg, duration = 3500) {
    if (!document.body) return;

    if (!_toasts[side]) {
      const host = document.createElement('div');
      host.id = `__ff_toast_host_${side}`;
      Object.assign(host.style, {
        position: 'fixed',
        zIndex: '2147483647',
        pointerEvents: 'none',
        ...(side === 'left'
          ? { bottom: '16px', left: '16px' }
          : { top: '16px', right: '16px' }),
      });
      document.body.appendChild(host);

      const root = host.attachShadow({ mode: 'open' });
      const bubble = document.createElement('div');
      Object.assign(bubble.style, {
        padding: '7px 12px', borderRadius: '4px',
        background: '#18181b', color: '#d4d4d8', border: '1px solid #3f3f46',
        fontSize: '11.5px', fontFamily: '"SF Mono",Menlo,Consolas,monospace',
        lineHeight: '1.6', letterSpacing: '0.02em',
        boxShadow: '0 4px 16px rgba(0,0,0,0.6)',
        transition: 'opacity 0.18s ease, transform 0.18s ease',
        opacity: '0', transform: 'translateY(4px)',
        maxWidth: '260px', userSelect: 'none', whiteSpace: 'normal',
      });
      root.appendChild(bubble);

      _toasts[side] = { host, bubble };
    }

    const { bubble } = _toasts[side];
    bubble.textContent = `ff  ${msg}`;
    bubble.style.opacity   = '1';
    bubble.style.transform = 'translateY(0)';
    clearTimeout(bubble.__tm);
    bubble.__tm = setTimeout(() => {
      bubble.style.opacity   = '0';
      bubble.style.transform = 'translateY(4px)';
    }, duration);
  }

  const toastLeft  = (msg, dur) => showToast('left',  msg, dur);
  const toastRight = (msg, dur) => showToast('right', msg, dur);

  // ── fill ──────────────────────────────────────────────────────────────────────

  function fill(emptyOnly = false) {
    const p = sessionProfile || (sessionProfile = makeProfile());

    const VALUES = {
      firstName: p.firstName, lastName: p.lastName, fullName: p.fullName,
      email: p.email, emailConfirm: p.emailConfirm,
      phone: p.phone, company: p.company, username: p.username,
      street: p.street, address2: p.address2,
      city: p.city, state: p.state, zip: p.zip, country: p.country,
      dob: p.dob, dobIso: p.dobIso,
      dobMonth: p.dobMonth, dobDay: p.dobDay, dobYear: p.dobYear,
      dobMonthName: p.dobMonthName,
      age: p.age, gender: p.gender, url: p.url,
    };

    let filled = 0, skipped = 0;

    document.querySelectorAll('input, select, textarea').forEach(el => {
      const tag = el.tagName.toLowerCase();

      if (tag === 'select') {
        if (emptyOnly && el.selectedIndex > 0) return void skipped++;
        const ft = detectFieldType(el);
        if (ft === 'skip' || ft === 'unknown') return;
        let ok = false;
        if      (ft === 'dobMonth') ok = setSelectValue(el, p.dobMonth, [p.dobMonthName, String(parseInt(p.dobMonth, 10))]);
        else if (ft === 'state')    ok = setSelectValue(el, p.state);
        else if (ft === 'country')  ok = setSelectValue(el, p.country, ['us', 'usa', 'united states of america']);
        else if (ft === 'gender')   ok = setSelectValue(el, p.gender, [p.gender === 'male' ? 'm' : 'f']);
        else if (VALUES[ft])        ok = setSelectValue(el, VALUES[ft]);
        if (ok) { flash(el); filled++; }
        return;
      }

      if (tag === 'textarea') {
        if (emptyOnly && el.value.trim()) return void skipped++;
        const ft = detectFieldType(el);
        if (ft === 'skip' || ft === 'unknown') return;
        if (VALUES[ft] && setInputValue(el, VALUES[ft])) { flash(el); filled++; }
        return;
      }

      // <input>
      if (emptyOnly && el.value.trim()) return void skipped++;
      const ft = detectFieldType(el);
      if (ft === 'skip' || ft === 'unknown') return;
      let val = VALUES[ft];
      if (val === undefined || val === null) return;
      if (val === '' && ft === 'address2') return;

      if (el.type === 'date')                        val = p.dobIso;
      if (el.type === 'number' && ft === 'age')      val = p.age;
      if (el.type === 'number' && ft === 'dobYear')  val = p.dobYear;
      if (el.type === 'number' && ft === 'dobMonth') val = String(parseInt(p.dobMonth, 10));
      if (el.type === 'number' && ft === 'dobDay')   val = String(parseInt(p.dobDay, 10));

      if (setInputValue(el, val)) { flash(el); filled++; }
    });

    const msg = filled > 0
      ? `filled ${filled} field${filled !== 1 ? 's' : ''}${skipped ? ` (${skipped} already set)` : ''}`
      : 'no fillable fields detected';
    toastRight(msg);
  }

  // ── run ───────────────────────────────────────────────────────────────────────

  async function run(emptyOnly = false, newProfile = false) {
    try {
      if (newProfile) sessionProfile = null;
      await ensureNames();
      if (!nameCache) return;
      fill(emptyOnly);
    } catch (err) {
      toastRight(`error: ${err.message}`, 6000);
      console.error('[FormFaker]', err);
    }
  }

  // ── commands + shortcuts ──────────────────────────────────────────────────────

  GM_registerMenuCommand('Fill Empty Fields',         () => run(true,  false), 'f');
  GM_registerMenuCommand('Fill Form (Overwrite All)', () => run(false, false), 'o');
  GM_registerMenuCommand('New Profile + Fill All',    () => run(false, true),  'n');

  document.addEventListener('keydown', e => {
    if (!e.altKey || !e.shiftKey) return;
    if (e.key === 'F') { e.preventDefault(); run(true,  false); } // Alt+Shift+F
    if (e.key === 'N') { e.preventDefault(); run(false, true);  } // Alt+Shift+N
  }, true);

})();