Fillr - Auto-fill Forms

Auto-fill job application forms instantly. Learns from your inputs, fills forms everywhere. Works on Google Forms, Greenhouse, Lever, Workday, and more.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

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

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

(I already have a user script manager, let me install it!)

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.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Fillr - Auto-fill Forms
// @namespace    https://fillr.app
// @version      4.0.0
// @description  Auto-fill job application forms instantly. Learns from your inputs, fills forms everywhere. Works on Google Forms, Greenhouse, Lever, Workday, and more.
// @author       Fillr
// @license      MIT
// @match        *://*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @run-at       document-idle
// @noframes
// ==/UserScript==

(function() {
'use strict';

// ============================================================================
// CONFIGURATION - ENTER YOUR GROQ API KEY HERE
// ============================================================================

const CONFIG = {
  llm: {
    url: 'https://api.groq.com/openai/v1/chat/completions',
    key: GM_getValue('groqApiKey', ''), // Will be set via settings
    model: 'llama-3.1-8b-instant'
  },
  debug: false
};

function hasGroqKey() {
  return CONFIG.llm.key && CONFIG.llm.key.startsWith('gsk_');
}

// ============================================================================
// SETTINGS UI
// ============================================================================

function showSettingsDialog() {
  const existing = document.getElementById('fillr-settings-modal');
  if (existing) existing.remove();

  const isEnabled = GM_getValue('enabled', true);

  const modal = document.createElement('div');
  modal.id = 'fillr-settings-modal';
  modal.innerHTML = `
    <div class="fillr-modal-backdrop"></div>
    <div class="fillr-modal-content">
      <h2>Fillr Settings</h2>

      <div class="fillr-field">
        <label>Auto-fill</label>
        <div class="fillr-toggle-wrapper">
          <label class="fillr-toggle">
            <input type="checkbox" id="fillr-enabled-toggle" ${isEnabled ? 'checked' : ''}>
            <span class="fillr-toggle-slider"></span>
          </label>
          <span id="fillr-toggle-label">${isEnabled ? 'Enabled' : 'Disabled'}</span>
        </div>
      </div>

      <div class="fillr-field">
        <label>Groq API Key <span class="fillr-required">Required</span></label>
        <input type="password" id="fillr-groq-key" placeholder="gsk_xxxxxxxxxxxxx" value="${CONFIG.llm.key}">
        <p class="fillr-hint">Get your free API key from <a href="https://console.groq.com/keys" target="_blank">console.groq.com/keys</a></p>
      </div>

      <div class="fillr-actions">
        <button id="fillr-save-settings" class="fillr-btn-primary">Save Settings</button>
        <button id="fillr-cancel-settings" class="fillr-btn-secondary">Cancel</button>
      </div>

      <hr style="margin: 20px 0; border: none; border-top: 1px solid #e5e5e5;">

      <div class="fillr-field">
        <label>Manage Profile Data</label>
        <div style="display: flex; gap: 10px; margin-top: 8px;">
          <button id="fillr-view-profile" class="fillr-btn-secondary">View Profile</button>
          <button id="fillr-clear-profile" class="fillr-btn-danger">Clear All Data</button>
        </div>
      </div>
    </div>
  `;

  document.body.appendChild(modal);

  modal.querySelector('.fillr-modal-backdrop').onclick = () => modal.remove();
  modal.querySelector('#fillr-cancel-settings').onclick = () => modal.remove();

  // Toggle change handler
  modal.querySelector('#fillr-enabled-toggle').onchange = (e) => {
    const enabled = e.target.checked;
    GM_setValue('enabled', enabled);
    state.enabled = enabled;
    modal.querySelector('#fillr-toggle-label').textContent = enabled ? 'Enabled' : 'Disabled';
    showToast(enabled ? 'Fillr enabled' : 'Fillr disabled', 'success');
  };

  modal.querySelector('#fillr-save-settings').onclick = () => {
    const groqKey = modal.querySelector('#fillr-groq-key').value.trim();

    if (groqKey && !groqKey.startsWith('gsk_')) {
      showToast('Groq API key should start with "gsk_"', 'error');
      return;
    }

    GM_setValue('groqApiKey', groqKey);
    CONFIG.llm.key = groqKey;

    showToast('Settings saved!', 'success');
    modal.remove();
  };

  modal.querySelector('#fillr-view-profile').onclick = () => {
    const profile = GM_getValue('profile', {});
    const entries = Object.entries(profile);
    if (entries.length === 0) {
      showToast('No profile data saved yet', 'info');
      return;
    }
    let msg = 'Saved Profile:\n\n';
    entries.forEach(([k, v]) => { msg += `${k}: ${v}\n`; });
    alert(msg);
  };

  modal.querySelector('#fillr-clear-profile').onclick = () => {
    if (confirm('Delete all saved profile data? This cannot be undone.')) {
      GM_setValue('profile', {});
      GM_setValue('mappingCache', {});
      showToast('All data cleared!', 'success');
    }
  };
}

GM_addStyle(`
  #fillr-settings-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999999; font-family: system-ui, -apple-system, sans-serif; }
  .fillr-modal-backdrop { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); }
  .fillr-modal-content { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 24px; border-radius: 12px; width: 400px; max-width: 90vw; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
  .fillr-modal-content h2 { margin: 0 0 20px 0; color: #5c4a32; font-size: 20px; }
  .fillr-field { margin-bottom: 16px; }
  .fillr-field label { display: block; font-size: 13px; color: #333; margin-bottom: 6px; font-weight: 500; }
  .fillr-required { background: #5c4a32; color: white; font-size: 10px; padding: 2px 6px; border-radius: 8px; margin-left: 6px; }
  .fillr-field input { width: 100%; padding: 10px 12px; border: 1px solid #d6d3d1; border-radius: 8px; font-size: 14px; box-sizing: border-box; }
  .fillr-field input:focus { outline: none; border-color: #5c4a32; }
  .fillr-hint { font-size: 12px; color: #666; margin-top: 4px; }
  .fillr-hint a { color: #5c4a32; }
  .fillr-actions { display: flex; gap: 10px; margin-top: 20px; }
  .fillr-btn-primary { background: #5c4a32; color: white; border: none; padding: 10px 20px; border-radius: 8px; font-size: 14px; cursor: pointer; }
  .fillr-btn-primary:hover { background: #44362a; }
  .fillr-btn-secondary { background: #e7e5e4; color: #57534e; border: none; padding: 10px 20px; border-radius: 8px; font-size: 14px; cursor: pointer; }
  .fillr-btn-secondary:hover { background: #d6d3d1; }
  .fillr-btn-danger { background: #dc2626; color: white; border: none; padding: 10px 20px; border-radius: 8px; font-size: 14px; cursor: pointer; }
  .fillr-btn-danger:hover { background: #b91c1c; }
  .fillr-toggle-wrapper { display: flex; align-items: center; gap: 12px; }
  .fillr-toggle { position: relative; display: inline-block; width: 50px; height: 26px; }
  .fillr-toggle input { opacity: 0; width: 0; height: 0; }
  .fillr-toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .3s; border-radius: 26px; }
  .fillr-toggle-slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: .3s; border-radius: 50%; }
  .fillr-toggle input:checked + .fillr-toggle-slider { background-color: #5c4a32; }
  .fillr-toggle input:checked + .fillr-toggle-slider:before { transform: translateX(24px); }
  #fillr-toggle-label { font-size: 14px; color: #57534e; }
`);

GM_registerMenuCommand('Fillr Settings', showSettingsDialog);
GM_registerMenuCommand('Fill Form Now', () => autoFill());
GM_registerMenuCommand('Save Form Data', () => saveProfile());

// ============================================================================
// MAPPINGS
// ============================================================================

const COMMON_MAPPINGS = {
  'name': 'name', 'fullname': 'name', 'full name': 'name', 'yourname': 'name', 'your name': 'name',
  'candidatename': 'name', 'candidate name': 'name', 'applicantname': 'name',
  'firstname': 'first name', 'first name': 'first name', 'fname': 'first name', 'givenname': 'first name',
  'lastname': 'last name', 'last name': 'last name', 'lname': 'last name', 'surname': 'last name', 'familyname': 'last name',
  'email': 'email', 'emailaddress': 'email', 'email address': 'email', 'youremail': 'email', 'mail': 'email', 'e-mail': 'email',
  'phone': 'phone', 'phonenumber': 'phone', 'phone number': 'phone', 'mobile': 'phone', 'mobilenumber': 'phone',
  'cell': 'phone', 'cellphone': 'phone', 'telephone': 'phone', 'tel': 'phone', 'contact': 'phone', 'contactnumber': 'phone',
  'currentctc': 'current ctc', 'current ctc': 'current ctc', 'currentsalary': 'current ctc', 'current salary': 'current ctc',
  'expectedctc': 'expected ctc', 'expected ctc': 'expected ctc', 'expectedsalary': 'expected ctc', 'expected salary': 'expected ctc', 'desiredsalary': 'expected ctc',
  'noticeperiod': 'notice period', 'notice period': 'notice period',
  'experience': 'experience', 'totalexperience': 'experience', 'total experience': 'experience', 'yearsofexperience': 'experience', 'workexperience': 'experience',
  'currentcompany': 'current company', 'current company': 'current company', 'currentemployer': 'current company', 'company': 'current company', 'employer': 'current company',
  'currentrole': 'current role', 'current role': 'current role', 'currenttitle': 'current role', 'jobtitle': 'current role', 'job title': 'current role',
  'designation': 'current role', 'position': 'current role', 'role': 'current role', 'title': 'current role',
  'location': 'location', 'city': 'location', 'currentlocation': 'location', 'current location': 'location',
  'linkedin': 'linkedin', 'linkedinurl': 'linkedin', 'linkedin url': 'linkedin', 'linkedinprofile': 'linkedin',
  'portfolio': 'portfolio', 'portfoliourl': 'portfolio', 'website': 'portfolio', 'personalwebsite': 'portfolio',
  'github': 'github', 'githuburl': 'github', 'github url': 'github', 'githubprofile': 'github',
  'gender': 'gender', 'sex': 'gender',
  'address': 'address', 'streetaddress': 'address', 'street address': 'address',
  'skills': 'skills', 'keyskills': 'skills', 'key skills': 'skills', 'technicalskills': 'skills'
};

const HTML_ATTR_MAPPINGS = {
  'email': 'email', 'tel': 'phone', 'url': 'portfolio', 'name': 'name',
  'given-name': 'first name', 'family-name': 'last name', 'organization': 'current company',
  'organization-title': 'current role', 'street-address': 'address'
};

// ============================================================================
// STATE
// ============================================================================

const state = {
  enabled: GM_getValue('enabled', true),
  profile: GM_getValue('profile', {}),
  mappingCache: GM_getValue('mappingCache', {}),
  hasAutoFilled: false,
  isFilling: false,
  isSaving: false
};

const log = (...args) => CONFIG.debug && console.log('[Fillr]', ...args);
const sleep = ms => new Promise(r => setTimeout(r, ms));

function loadLocalData() {
  state.profile = GM_getValue('profile', {});
  state.mappingCache = GM_getValue('mappingCache', {});
}

function saveLocalData() {
  GM_setValue('profile', state.profile);
  GM_setValue('mappingCache', state.mappingCache);
}

// ============================================================================
// UTILITIES
// ============================================================================

function showToast(message, type = 'info') {
  const existing = document.getElementById('fillr-toast');
  if (existing) existing.remove();

  const colors = { info: '#5c4a32', success: '#16a34a', error: '#dc2626' };
  const toast = document.createElement('div');
  toast.id = 'fillr-toast';
  toast.textContent = message;
  toast.style.cssText = `position: fixed; top: 20px; right: 20px; background: ${colors[type]}; color: white; padding: 12px 20px; border-radius: 8px; font-family: system-ui, sans-serif; font-size: 14px; z-index: 999999; box-shadow: 0 4px 12px rgba(0,0,0,0.2);`;
  document.body.appendChild(toast);
  setTimeout(() => toast.remove(), 2500);
}

function normalizeText(text) { return text ? text.replace(/\s+/g, ' ').replace(/[*:]/g, '').trim() : ''; }

function normalizeLabel(label) {
  return label.toLowerCase().replace(/^(your|my|the|candidate['']?s?|applicant['']?s?)\s+/i, '').replace(/\s+/g, '').replace(/[^a-z0-9]/g, '');
}

function levenshtein(a, b) {
  const matrix = [];
  for (let i = 0; i <= b.length; i++) matrix[i] = [i];
  for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
  for (let i = 1; i <= b.length; i++) {
    for (let j = 1; j <= a.length; j++) {
      matrix[i][j] = b[i-1] === a[j-1] ? matrix[i-1][j-1] : Math.min(matrix[i-1][j-1]+1, matrix[i][j-1]+1, matrix[i-1][j]+1);
    }
  }
  return matrix[b.length][a.length];
}

function similarity(a, b) { const m = Math.max(a.length, b.length); return m === 0 ? 1 : 1 - levenshtein(a, b) / m; }

function isVisible(el) {
  if (!el) return false;
  try {
    const s = getComputedStyle(el);
    if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') return false;
    const r = el.getBoundingClientRect();
    return r.width > 0 && r.height > 0;
  } catch { return false; }
}

function isInteractable(el) {
  return el && isVisible(el) && !el.disabled && !el.readOnly && el.getAttribute('aria-disabled') !== 'true';
}

function triggerEvents(el, value) {
  el.focus();
  const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : el.tagName === 'SELECT' ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
  const desc = Object.getOwnPropertyDescriptor(proto, 'value');
  if (desc?.set) desc.set.call(el, value); else el.value = value;
  if (el._valueTracker) el._valueTracker.setValue('');
  ['input', 'change'].forEach(t => el.dispatchEvent(new Event(t, { bubbles: true })));
  el.dispatchEvent(new Event('blur', { bubbles: true }));
}

function querySelectorAllDeep(selector, root = document) {
  const elements = [];
  function traverse(node) {
    try { elements.push(...node.querySelectorAll(selector)); } catch {}
    if (node.shadowRoot) traverse(node.shadowRoot);
    try { node.querySelectorAll('*').forEach(el => { if (el.shadowRoot) traverse(el.shadowRoot); }); } catch {}
  }
  traverse(root);
  return elements;
}

function extractLabel(el, doc = document) {
  if (el.id) { try { const lbl = doc.querySelector(`label[for="${CSS.escape(el.id)}"]`); if (lbl) { const t = normalizeText(lbl.textContent); if (t && t.length > 1 && t.length < 150) return t; } } catch {} }
  const aria = el.getAttribute('aria-label'); if (aria) return normalizeText(aria);
  const wrap = el.closest('label');
  if (wrap) { const clone = wrap.cloneNode(true); clone.querySelectorAll('input,select,textarea,button').forEach(e => e.remove()); const t = normalizeText(clone.textContent); if (t && t.length > 1 && t.length < 150) return t; }
  let parent = el.parentElement;
  for (let i = 0; i < 6 && parent; i++) {
    const labelEl = parent.querySelector('label, [class*="label" i], [class*="title" i], [class*="question" i]');
    if (labelEl && !labelEl.contains(el) && labelEl !== el) { const t = normalizeText(labelEl.textContent); if (t && t.length > 1 && t.length < 150) return t; }
    parent = parent.parentElement;
  }
  const ph = el.getAttribute('placeholder'); if (ph && ph.length < 100) return normalizeText(ph);
  if (el.name) return el.name.replace(/[\[\]_-]/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').trim();
  return null;
}

// ============================================================================
// FIELD DETECTION
// ============================================================================

function detectPlatform() {
  const h = location.hostname.toLowerCase(), u = location.href.toLowerCase();
  if (u.includes('docs.google.com/forms')) return 'google_forms';
  if (h.includes('greenhouse')) return 'greenhouse';
  if (h.includes('lever')) return 'lever';
  if (h.includes('workday')) return 'workday';
  return 'standard';
}

function getFieldType(el) {
  const tag = el.tagName?.toUpperCase(), type = (el.type || '').toLowerCase();
  if (tag === 'SELECT') return 'select';
  if (tag === 'TEXTAREA') return 'textarea';
  if (type === 'radio') return 'radio';
  if (type === 'checkbox') return 'checkbox';
  if (['email', 'tel', 'url', 'number'].includes(type)) return type;
  if (type === 'file') return 'file';
  if (el.getAttribute('contenteditable') === 'true') return 'contenteditable';
  return 'text';
}

function detectGoogleFormFields() {
  const fields = [];
  document.querySelectorAll('[role="listitem"]').forEach(container => {
    const headingEl = container.querySelector('[role="heading"]');
    const label = normalizeText(headingEl?.textContent);
    if (!label) return;
    let type = 'text', options = [], element = null;
    const radios = container.querySelectorAll('[role="radio"]');
    const checkboxes = container.querySelectorAll('[role="checkbox"]');
    const listbox = container.querySelector('[role="listbox"]');
    const textInput = container.querySelector('input[type="text"], input:not([type])');
    const textArea = container.querySelector('textarea');
    if (radios.length > 0) { type = 'google-radio'; element = container; radios.forEach(r => { const val = r.getAttribute('data-value') || normalizeText(r.textContent); if (val) options.push({ value: val, text: val, element: r }); }); }
    else if (checkboxes.length > 0) { type = 'google-checkbox'; element = container; checkboxes.forEach(c => { const val = c.getAttribute('data-value') || normalizeText(c.textContent); if (val) options.push({ value: val, text: val, element: c }); }); }
    else if (listbox) { type = 'google-dropdown'; element = listbox; }
    else if (textArea) { type = 'google-textarea'; element = textArea; }
    else if (textInput) { type = 'google-text'; element = textInput; }
    else return;
    fields.push({ element, container, label, type, options, platform: 'google_forms' });
  });
  return fields;
}

function detectFormFields() {
  const platform = detectPlatform();
  if (platform === 'google_forms') return detectGoogleFormFields();
  const fields = [], seen = new WeakSet();
  const selectors = ['input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="file"])', 'textarea', 'select', '[contenteditable="true"]'];
  selectors.forEach(selector => {
    try {
      querySelectorAllDeep(selector).forEach(el => {
        if (seen.has(el) || !isInteractable(el)) return;
        const type = (el.type || '').toLowerCase();
        if (type === 'search' || type === 'hidden') return;
        const label = extractLabel(el);
        if (!label) return;
        seen.add(el);
        const fieldType = getFieldType(el);
        if (fieldType === 'file') return;
        const field = { element: el, label, type: fieldType, options: [], platform };
        if (fieldType === 'select') { Array.from(el.options || []).forEach(opt => { if (opt.value && !opt.disabled) field.options.push({ value: opt.value, text: normalizeText(opt.textContent) }); }); }
        else if (fieldType === 'radio' && el.name) { document.querySelectorAll(`input[type="radio"][name="${CSS.escape(el.name)}"]`).forEach(radio => { seen.add(radio); const optLabel = extractLabel(radio) || radio.value; if (optLabel) field.options.push({ value: radio.value, text: optLabel, element: radio }); }); }
        fields.push(field);
      });
    } catch {}
  });
  return fields;
}

// ============================================================================
// MATCHING
// ============================================================================

function matchField(field) {
  const formLabel = field.label, normalized = normalizeLabel(formLabel);
  const inputType = field.element.type?.toLowerCase(), autocomplete = field.element.getAttribute('autocomplete');
  if (inputType && HTML_ATTR_MAPPINGS[inputType] && state.profile[HTML_ATTR_MAPPINGS[inputType]]) return { canonical: HTML_ATTR_MAPPINGS[inputType], value: state.profile[HTML_ATTR_MAPPINGS[inputType]] };
  if (autocomplete && HTML_ATTR_MAPPINGS[autocomplete] && state.profile[HTML_ATTR_MAPPINGS[autocomplete]]) return { canonical: HTML_ATTR_MAPPINGS[autocomplete], value: state.profile[HTML_ATTR_MAPPINGS[autocomplete]] };
  const prebuilt = COMMON_MAPPINGS[normalized] || COMMON_MAPPINGS[formLabel.toLowerCase()];
  if (prebuilt && state.profile[prebuilt]) return { canonical: prebuilt, value: state.profile[prebuilt] };
  for (const key of Object.keys(state.profile)) { if (normalizeLabel(key) === normalized) return { canonical: key, value: state.profile[key] }; }
  for (const key of Object.keys(state.profile)) { if (similarity(normalized, normalizeLabel(key)) >= 0.85) return { canonical: key, value: state.profile[key] }; }
  if (state.mappingCache[normalized] && state.profile[state.mappingCache[normalized]]) return { canonical: state.mappingCache[normalized], value: state.profile[state.mappingCache[normalized]] };
  return null;
}

async function matchWithLLM(unmatchedFields, profileLabels) {
  if (!unmatchedFields.length || !profileLabels.length || !hasGroqKey()) return {};
  const formLabels = unmatchedFields.map(f => f.label);
  const prompt = `Match form labels to profile labels. Only match if same info.\nFORM: ${JSON.stringify(formLabels)}\nPROFILE: ${JSON.stringify(profileLabels)}\nReturn JSON: {"matches": {"<form>": "<profile_or_null>", ...}}`;
  try {
    const res = await fetch(CONFIG.llm.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${CONFIG.llm.key}` },
      body: JSON.stringify({ model: CONFIG.llm.model, messages: [{ role: 'user', content: prompt }], temperature: 0, response_format: { type: 'json_object' } })
    });
    const data = await res.json();
    const result = JSON.parse(data.choices?.[0]?.message?.content || '{}');
    if (result.matches) { for (const [f, p] of Object.entries(result.matches)) { if (p) state.mappingCache[normalizeLabel(f)] = p; } saveLocalData(); }
    return result.matches || {};
  } catch { return {}; }
}

// ============================================================================
// FILL/SAVE
// ============================================================================

async function fillField(field, value) {
  if (!value) return false;
  const { element, type, options } = field;
  try {
    switch (type) {
      case 'text': case 'email': case 'tel': case 'url': case 'number': case 'textarea':
        element.focus(); await sleep(20); triggerEvents(element, value); return true;
      case 'select': { let m = options.find(o => o.text.toLowerCase() === value.toLowerCase()) || options.find(o => o.text.toLowerCase().includes(value.toLowerCase())); if (m) { element.value = m.value; element.dispatchEvent(new Event('change', { bubbles: true })); return true; } return false; }
      case 'radio': { let m = options.find(o => o.text.toLowerCase() === value.toLowerCase()) || options.find(o => o.text.toLowerCase().includes(value.toLowerCase())); if (m?.element) { m.element.checked = true; m.element.dispatchEvent(new Event('change', { bubbles: true })); return true; } return false; }
      case 'checkbox': element.checked = ['true', 'yes', '1'].includes(value.toLowerCase()); element.dispatchEvent(new Event('change', { bubbles: true })); return true;
      case 'contenteditable': element.focus(); element.textContent = value; element.dispatchEvent(new Event('input', { bubbles: true })); return true;
      case 'google-text': case 'google-textarea': element.focus(); await sleep(50); triggerEvents(element, value); return true;
      case 'google-radio': { let m = options.find(o => o.text.toLowerCase() === value.toLowerCase()) || options.find(o => o.text.toLowerCase().includes(value.toLowerCase())); if (m?.element) { m.element.click(); return true; } return false; }
      case 'google-checkbox': { const vals = value.split(',').map(v => v.trim().toLowerCase()); let clicked = false; options.forEach(o => { if (vals.some(v => o.text.toLowerCase().includes(v)) && o.element.getAttribute('aria-checked') !== 'true') { o.element.click(); clicked = true; } }); return clicked; }
      case 'google-dropdown': { element.click(); await sleep(300); for (const opt of document.querySelectorAll('[role="option"]')) { if (opt.textContent.toLowerCase().includes(value.toLowerCase())) { opt.click(); return true; } } return false; }
      default: if ('value' in element) { triggerEvents(element, value); return true; } return false;
    }
  } catch { return false; }
}

function extractValue(field) {
  const { element, type, options } = field;
  try {
    switch (type) {
      case 'select': return element.options[element.selectedIndex]?.textContent?.trim() || '';
      case 'radio': return options.find(o => o.element?.checked)?.text || '';
      case 'checkbox': return element.checked ? 'Yes' : '';
      case 'contenteditable': return element.textContent?.trim() || '';
      case 'google-text': case 'google-textarea': return element.value?.trim() || '';
      case 'google-radio': return options.find(o => o.element?.getAttribute('aria-checked') === 'true')?.text || '';
      case 'google-checkbox': return options.filter(o => o.element?.getAttribute('aria-checked') === 'true').map(o => o.text).join(', ');
      default: return element.value?.trim() || '';
    }
  } catch { return ''; }
}

async function autoFill() {
  if (state.isFilling) return;
  state.isFilling = true;
  try {
    loadLocalData();
    const fields = detectFormFields();
    if (!fields.length) { showToast('No form fields found', 'info'); return; }
    const profileLabels = Object.keys(state.profile);
    if (!profileLabels.length) { showToast('No profile data. Fill a form first!', 'info'); return; }
    const matched = [], unmatched = [];
    for (const field of fields) { const m = matchField(field); if (m) matched.push({ field, ...m }); else unmatched.push(field); }
    let filled = 0;
    for (const { field, value } of matched) { if (await fillField(field, value)) filled++; await sleep(50); }
    if (unmatched.length && profileLabels.length) {
      const llm = await matchWithLLM(unmatched, profileLabels);
      for (const field of unmatched) { const p = llm[field.label]; if (p && state.profile[p] && await fillField(field, state.profile[p])) filled++; await sleep(50); }
    }
    showToast(filled > 0 ? `Filled ${filled}/${fields.length} fields` : 'No fields matched', filled > 0 ? 'success' : 'info');
  } finally { state.isFilling = false; }
}

async function saveProfile() {
  if (state.isSaving) return;
  state.isSaving = true;
  try {
    loadLocalData();
    const fields = detectFormFields();
    const formData = fields.map(f => ({ label: f.label, value: extractValue(f), field: f })).filter(x => x.value);
    if (!formData.length) { showToast('No data to save', 'info'); return; }
    const profileLabels = Object.keys(state.profile);
    const toSave = [], unmatchedForLLM = [];
    for (const item of formData) { const m = matchField(item.field); if (m) toSave.push({ canonical: m.canonical, value: item.value }); else unmatchedForLLM.push(item); }
    if (unmatchedForLLM.length && profileLabels.length) {
      const llm = await matchWithLLM(unmatchedForLLM.map(u => u.field), profileLabels);
      for (const item of unmatchedForLLM) { const p = llm[item.label]; if (p && state.profile[p] !== undefined) toSave.push({ canonical: p, value: item.value }); else { const n = normalizeLabel(item.label); toSave.push({ canonical: COMMON_MAPPINGS[n] || COMMON_MAPPINGS[item.label.toLowerCase()] || item.label.toLowerCase().trim(), value: item.value }); } }
    } else { for (const item of unmatchedForLLM) { const n = normalizeLabel(item.label); toSave.push({ canonical: COMMON_MAPPINGS[n] || COMMON_MAPPINGS[item.label.toLowerCase()] || item.label.toLowerCase().trim(), value: item.value }); } }
    for (const { canonical, value } of toSave) state.profile[canonical] = value;
    saveLocalData();
    showToast(`Saved ${toSave.length} fields`, 'success');
  } finally { state.isSaving = false; }
}

// ============================================================================
// AUTO-SAVE ON BLUR
// ============================================================================

function getLabelForElement(el) {
  const label = extractLabel(el);
  if (label) return label;
  let parent = el.parentElement;
  for (let i = 0; i < 15 && parent; i++) { const h = parent.querySelector('[role="heading"]'); if (h) { const t = normalizeText(h.textContent); if (t && t.length > 1) return t; } parent = parent.parentElement; }
  return null;
}

function getFieldValue(el) {
  const tag = el.tagName?.toUpperCase(), type = (el.type || '').toLowerCase();
  if (tag === 'SELECT') return el.options[el.selectedIndex]?.textContent?.trim() || '';
  if (type === 'checkbox') return el.checked ? 'Yes' : '';
  if (type === 'radio') return el.checked ? (extractLabel(el) || el.value) : '';
  if (el.getAttribute('contenteditable')) return el.textContent?.trim() || '';
  return el.value?.trim() || '';
}

function saveFieldToLocal(label, value) {
  if (!label || !value) return;
  loadLocalData();
  const n = normalizeLabel(label);
  let canonical = COMMON_MAPPINGS[n] || COMMON_MAPPINGS[label.toLowerCase()];
  if (!canonical) { for (const k of Object.keys(state.profile)) { if (normalizeLabel(k) === n || similarity(n, normalizeLabel(k)) >= 0.85) { canonical = k; break; } } }
  if (!canonical && state.mappingCache[n]) canonical = state.mappingCache[n];
  if (!canonical) canonical = label.toLowerCase().trim();
  state.profile[canonical] = value;
  saveLocalData();
}

document.addEventListener('blur', e => { const el = e.target, label = getLabelForElement(el), value = getFieldValue(el); if (label && value) saveFieldToLocal(label, value); }, true);
document.addEventListener('change', e => { const el = e.target, label = getLabelForElement(el), value = getFieldValue(el); if (label && value) saveFieldToLocal(label, value); }, true);
document.addEventListener('click', e => {
  const radio = e.target.closest('[role="radio"], [role="checkbox"]');
  if (!radio) return;
  setTimeout(() => {
    if (radio.getAttribute('aria-checked') === 'true') {
      let parent = radio.parentElement;
      for (let i = 0; i < 15 && parent; i++) { const h = parent.querySelector('[role="heading"]'); if (h) { const label = normalizeText(h.textContent), value = radio.getAttribute('data-value') || normalizeText(radio.textContent); if (label && value) saveFieldToLocal(label, value); break; } parent = parent.parentElement; }
    }
  }, 50);
}, true);

// ============================================================================
// AUTO-FILL ON JOB SITES
// ============================================================================

async function autoFillOnLoad() {
  const h = location.hostname.toLowerCase(), u = location.href.toLowerCase();
  const isJobSite = u.includes('docs.google.com/forms') || h.includes('greenhouse') || h.includes('lever') || h.includes('workday') ||
    h.includes('smartrecruiters') || h.includes('ashby') || h.includes('icims') || h.includes('jobvite') ||
    u.includes('/careers') || u.includes('/jobs') || u.includes('/apply');
  if (!isJobSite || state.hasAutoFilled) return;
  for (let i = 0; i < 15; i++) { await sleep(1000); if (detectFormFields().length > 0) break; }
  loadLocalData();
  if (Object.keys(state.profile).length === 0) return;
  state.hasAutoFilled = true;
  await autoFill();
}

// ============================================================================
// INIT
// ============================================================================

log('Fillr loaded');
if (!hasGroqKey()) console.log('[Fillr] No Groq API key. Click Tampermonkey icon > Fillr Settings to add one.');
autoFillOnLoad();

})();