e621 Tag Extractor 2.0

Extract tags from e621.net

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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         e621 Tag Extractor 2.0
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  Extract tags from e621.net
// @author       cemtrex
// @match        https://e621.net/posts/*
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const BTN_ID = 'e621TagExtractButton';
  const MODAL_ID = 'e621TagExtractModal';

  const PREF_KEY = 'e621TagExtractorPrefs_v2';
  const HISTORY_KEY = 'e621TagExtractorHistory_v2';
  const HISTORY_MAX = 50;

  const DEFAULT_PREFS = {
    wordStyle: 'underscores',
    separator: ', ',
    preselectGroups: ['general', 'species', 'character', 'artist', 'copyright', 'meta', 'lore', 'invalid'],
    searchText: '',
    blacklist: '',
    autoApplyBlacklist: true
  };

  const unique = (arr) => Array.from(new Set(arr));
  const clampArray = (arr) => Array.isArray(arr) ? arr : [];

  const loadPrefs = () => {
    try { return Object.assign({}, DEFAULT_PREFS, JSON.parse(localStorage.getItem(PREF_KEY) || '{}')); }
    catch { return { ...DEFAULT_PREFS }; }
  };
  const savePrefs = (p) => { try { localStorage.setItem(PREF_KEY, JSON.stringify(p)); } catch {} };

  const loadHistory = () => {
    try { return clampArray(JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]')); }
    catch { return []; }
  };
  const saveHistory = (arr) => { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(clampArray(arr))); } catch {} };

  function enforceHistoryMax(arr) {
    while (arr.length > HISTORY_MAX) {
      let idx = -1;
      for (let i = arr.length - 1; i >= 0; i--) {
        if (!arr[i]?.pinned) { idx = i; break; }
      }
      if (idx === -1) idx = arr.length - 1;
      arr.splice(idx, 1);
    }
    return arr;
  }

  function upsertHistoryEntry(entry) {
    const arr = loadHistory();
    const idx = arr.findIndex(x => String(x?.postId) === String(entry?.postId));

    if (idx !== -1) {
      const old = arr[idx] || {};
      entry = Object.assign({}, old, entry, {
        pinned: !!old.pinned,
        copiedText: old.copiedText || '',
        copiedCount: old.copiedCount || 0,
        copiedTs: old.copiedTs || 0
      });
      arr.splice(idx, 1);
    } else {
      entry = Object.assign({
        pinned: false,
        copiedText: '',
        copiedCount: 0,
        copiedTs: 0
      }, entry);
    }

    arr.unshift(entry);
    enforceHistoryMax(arr);
    saveHistory(arr);
    return entry;
  }

  function setHistoryPinned(postId, pinned) {
    const arr = loadHistory();
    const idx = arr.findIndex(x => String(x?.postId) === String(postId));
    if (idx === -1) return;
    arr[idx].pinned = !!pinned;
    saveHistory(arr);
  }

  function updateHistoryCopied(postId, copiedText, copiedCount) {
    const arr = loadHistory();
    const idx = arr.findIndex(x => String(x?.postId) === String(postId));
    if (idx === -1) return;
    arr[idx].copiedText = String(copiedText || '');
    arr[idx].copiedCount = Number.isFinite(+copiedCount) ? +copiedCount : (arr[idx].copiedCount || 0);
    arr[idx].copiedTs = Date.now();
    saveHistory(arr);
  }

  function clearHistoryNonPinned() {
    saveHistory(loadHistory().filter(x => x && x.pinned));
  }

  function getCurrentPostId() {
    const m = location.pathname.match(/\/posts\/(\d+)/);
    return m ? m[1] : null;
  }

  function copyToClipboard(text) {
    if (navigator.clipboard && window.isSecureContext) {
      return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
    }
    return Promise.resolve(fallbackCopy(text));
  }

  function fallbackCopy(text) {
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.left = '-9999px';
    ta.setAttribute('readonly', '');
    document.body.appendChild(ta);
    ta.select();
    try { document.execCommand('copy'); } catch {}
    ta.remove();
  }

  async function getTagsFromAPI() {
    const postId = getCurrentPostId();
    if (!postId) return { flat: [], grouped: {}, order: [], postId: null, previewUrl: null };

    try {
      const res = await fetch(`/posts/${postId}.json`, {
        headers: { 'Accept': 'application/json' },
        credentials: 'same-origin',
        cache: 'no-store'
      });

      if (!res.ok) return { flat: [], grouped: {}, order: [], postId, previewUrl: null };

      const data = await res.json();
      const post = data.post || data;
      const tagGroups = post.tags || {};

      const grouped = Array.isArray(tagGroups)
        ? { general: tagGroups.map(String) }
        : Object.fromEntries(Object.entries(tagGroups).map(([k, v]) => [k, (v || []).map(String)]));

      const preferred = ['artist', 'species', 'character', 'copyright', 'general', 'meta', 'lore', 'invalid'];
      const order = Object.keys(grouped).sort((a, b) => {
        const ia = preferred.indexOf(a);
        const ib = preferred.indexOf(b);
        const sa = ia === -1 ? 1e9 : ia;
        const sb = ib === -1 ? 1e9 : ib;
        return (sa - sb) || a.localeCompare(b);
      });

      const flat = unique(Object.values(grouped).flat().map(t => t.trim()).filter(Boolean));

      const previewUrl =
        (post.preview && post.preview.url) ||
        (post.sample && post.sample.url) ||
        (post.file && post.file.url) ||
        null;

      return { flat, grouped, order, postId, previewUrl };
    } catch (e) {
      console.error('[e621 Tag Extractor] API error:', e);
      return { flat: [], grouped: {}, order: [], postId, previewUrl: null };
    }
  }

  function buildButton() {
    const button = document.createElement('button');
    button.id = BTN_ID;
    button.type = 'button';
    button.textContent = '+';
    button.title = 'Extract, filter, review, and copy tags';
    button.setAttribute('aria-label', 'Extract, filter, review, and copy tags');
    Object.assign(button.style, {
      position: 'fixed',
      bottom: '20px',
      right: '20px',
      backgroundColor: '#2563eb',
      color: '#fff',
      border: 'none',
      borderRadius: '50%',
      width: '50px',
      height: '50px',
      textAlign: 'center',
      fontSize: '24px',
      lineHeight: '50px',
      cursor: 'pointer',
      boxShadow: '2px 2px 6px rgba(0,0,0,0.3)',
      zIndex: 2147483647
    });
    return button;
  }

  function parsePatterns(text) {
    const raw = (text || '')
      .split(/[\n,]+/g)
      .map(s => s.trim())
      .filter(Boolean);

    const out = [];
    for (const p of raw) {
      const m = p.match(/^\/(.+)\/([gimsuy]*)$/);
      if (m) {
        try { out.push({ type: 'regex', re: new RegExp(m[1], m[2]) }); } catch {}
      } else {
        out.push({ type: 'substr', s: p.toLowerCase() });
      }
    }
    return out;
  }

  function matchesAny(tag, patterns) {
    if (!patterns?.length) return false;
    for (const p of patterns) {
      if (p.type === 'regex') {
        try { if (p.re.test(tag)) return true; } catch {}
      } else if (tag.toLowerCase().includes(p.s)) {
        return true;
      }
    }
    return false;
  }

  let toastTimer = null;
  function makeToast(text) {
    clearTimeout(toastTimer);
    const existing = document.getElementById('e621TagExtractorToast');
    if (existing) existing.remove();

    const toast = document.createElement('div');
    toast.id = 'e621TagExtractorToast';
    toast.textContent = text;

    Object.assign(toast.style, {
      position: 'fixed',
      left: '50%',
      top: '50%',
      transform: 'translate(-50%, -50%)',
      background: 'rgba(29,78,216,0.92)',
      border: '1px solid rgba(29,78,216,1)',
      color: '#ffffff',
      padding: '14px 18px',
      borderRadius: '14px',
      zIndex: 2147483647,
      fontSize: '16px',
      fontWeight: '700',
      letterSpacing: '0.2px',
      textAlign: 'center',
      boxShadow: '0 14px 34px rgba(0,0,0,0.55)',
      opacity: '0',
      transition: 'opacity 380ms ease'
    });

    document.body.appendChild(toast);
    requestAnimationFrame(() => { toast.style.opacity = '1'; });

    toastTimer = setTimeout(() => {
      toast.style.opacity = '0';
      setTimeout(() => toast.remove(), 420);
    }, 2000);
  }

  function formatTime(ts) {
    try { return new Date(ts).toLocaleString(); } catch { return String(ts); }
  }

  function ymd(ts) {
    try {
      const d = new Date(ts);
      const y = d.getFullYear();
      const m = String(d.getMonth() + 1).padStart(2, '0');
      const day = String(d.getDate()).padStart(2, '0');
      return `${y}-${m}-${day}`;
    } catch { return ''; }
  }

  function makeConfirmWindow({ overlay, title, message, yesText, cancelText, centerTitleOnly, onYes, onClose }) {
    const panel = document.createElement('div');
    Object.assign(panel.style, {
      position: 'fixed',
      inset: '0',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      zIndex: 2147483647
    });

    const box = document.createElement('div');
    Object.assign(box.style, {
      width: 'min(420px, 92vw)',
      overflow: 'hidden',
      borderRadius: '12px',
      border: '1px solid #1f2937',
      background: '#0b1220',
      boxShadow: '0 12px 34px rgba(0,0,0,0.6)',
      color: '#e5e7eb',
      display: 'flex',
      flexDirection: 'column'
    });

    const head = document.createElement('div');
    Object.assign(head.style, {
      padding: '14px 14px',
      background: '#111827',
      borderBottom: '1px solid #1f2937',
      display: 'flex',
      alignItems: 'center',
      justifyContent: centerTitleOnly ? 'center' : 'space-between',
      gap: '10px',
      fontWeight: '800',
      textAlign: 'center'
    });

    const t = document.createElement('div');
    t.textContent = title || 'Are you sure?';

    const x = document.createElement('button');
    x.type = 'button';
    x.textContent = '✕';
    Object.assign(x.style, {
      width: '32px',
      height: '32px',
      borderRadius: '8px',
      border: '1px solid #374151',
      background: '#1f2937',
      color: '#e5e7eb',
      cursor: 'pointer',
      fontSize: '16px',
      display: centerTitleOnly ? 'none' : 'inline-block'
    });

    head.append(t);
    if (!centerTitleOnly) head.append(x);

    const body = document.createElement('div');
    Object.assign(body.style, { padding: message ? '14px 14px' : '0', fontSize: '13px', opacity: '0.95', textAlign: 'center' });
    body.textContent = message || '';

    const actions = document.createElement('div');
    Object.assign(actions.style, {
      padding: '12px 14px',
      borderTop: '1px solid #1f2937',
      display: 'flex',
      justifyContent: 'space-between',
      gap: '10px',
      background: '#111827'
    });

    const cancel = document.createElement('button');
    cancel.type = 'button';
    cancel.textContent = cancelText || 'Cancel';
    Object.assign(cancel.style, {
      padding: '8px 12px',
      borderRadius: '8px',
      border: '1px solid #1d4ed8',
      background: '#2563eb',
      color: '#fff',
      cursor: 'pointer'
    });

    const yes = document.createElement('button');
    yes.type = 'button';
    yes.textContent = yesText || 'Confirm';
    Object.assign(yes.style, {
      padding: '8px 12px',
      borderRadius: '8px',
      border: '1px solid #7f1d1d',
      background: '#b91c1c',
      color: '#fff',
      cursor: 'pointer'
    });

    actions.append(cancel, yes);

    function close() {
      panel.remove();
      if (typeof onClose === 'function') onClose();
    }

    x.onclick = close;
    cancel.onclick = close;
    yes.onclick = () => { try { onYes?.(); } finally { close(); } };

    panel.appendChild(box);
    box.append(head, message ? body : document.createElement('div'), actions);
    (overlay || document.body).appendChild(panel);

    return { close };
  }

  function makeCopiedTagsWindow({ overlay, text, onClose }) {
    const panel = document.createElement('div');
    Object.assign(panel.style, {
      position: 'fixed',
      inset: '0',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      zIndex: 2147483647
    });

    const box = document.createElement('div');
    Object.assign(box.style, {
      width: 'min(760px, 92vw)',
      maxHeight: '80vh',
      overflow: 'hidden',
      borderRadius: '10px',
      border: '1px solid #1f2937',
      background: '#0b1220',
      boxShadow: '0 10px 30px rgba(0,0,0,0.55)',
      color: '#e5e7eb',
      display: 'flex',
      flexDirection: 'column'
    });

    const head = document.createElement('div');
    Object.assign(head.style, {
      padding: '12px 14px',
      background: '#111827',
      borderBottom: '1px solid #1f2937',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'space-between',
      gap: '10px',
      fontWeight: '600'
    });

    const title = document.createElement('div');
    title.textContent = 'Copied tags';

    const close = document.createElement('button');
    close.type = 'button';
    close.textContent = '✕';
    Object.assign(close.style, {
      width: '32px',
      height: '32px',
      borderRadius: '8px',
      border: '1px solid #374151',
      background: '#1f2937',
      color: '#e5e7eb',
      cursor: 'pointer',
      fontSize: '16px'
    });

    head.append(title, close);

    const body = document.createElement('div');
    Object.assign(body.style, { padding: '10px 12px', overflowY: 'auto' });

    const ta = document.createElement('textarea');
    ta.value = text || '';
    Object.assign(ta.style, {
      width: '100%',
      height: '260px',
      resize: 'vertical',
      boxSizing: 'border-box',
      padding: '10px',
      borderRadius: '10px',
      border: '1px solid #374151',
      background: '#111827',
      color: '#e5e7eb',
      outline: 'none'
    });
    body.appendChild(ta);

    const actions = document.createElement('div');
    Object.assign(actions.style, {
      padding: '10px 12px',
      borderTop: '1px solid #1f2937',
      display: 'flex',
      justifyContent: 'flex-end',
      gap: '8px',
      background: '#111827'
    });

    const copyAgain = document.createElement('button');
    copyAgain.type = 'button';
    copyAgain.textContent = 'Copy again';
    Object.assign(copyAgain.style, {
      padding: '8px 12px',
      borderRadius: '8px',
      border: '1px solid #1d4ed8',
      background: '#2563eb',
      color: '#e5e7eb',
      cursor: 'pointer'
    });
    actions.append(copyAgain);

    copyAgain.onclick = async () => {
      const v = ta.value || '';
      if (!v) return;
      await copyToClipboard(v);
      makeToast('Copied');
    };

    function doClose() {
      panel.remove();
      onClose?.();
    }
    close.onclick = doClose;

    panel.appendChild(box);
    box.append(head, body, actions);
    overlay.appendChild(panel);

    return { close: doClose };
  }

  function makeHistoryWindow({ overlay, onCloseEscChange, onClose }) {
    const panel = document.createElement('div');
    Object.assign(panel.style, {
      position: 'fixed',
      inset: '0',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      zIndex: 2147483647
    });

    const box = document.createElement('div');
    Object.assign(box.style, {
      width: 'min(820px, 92vw)',
      maxHeight: '82vh',
      overflow: 'hidden',
      borderRadius: '10px',
      border: '1px solid #1f2937',
      background: '#0b1220',
      boxShadow: '0 10px 30px rgba(0,0,0,0.55)',
      color: '#e5e7eb',
      display: 'flex',
      flexDirection: 'column'
    });

    const head = document.createElement('div');
    Object.assign(head.style, {
      padding: '12px 14px',
      background: '#111827',
      borderBottom: '1px solid #1f2937',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'space-between',
      gap: '10px'
    });

    const left = document.createElement('div');
    Object.assign(left.style, { display: 'flex', flexDirection: 'column', gap: '8px', flex: '1' });

    const title = document.createElement('div');
    title.textContent = 'History';
    Object.assign(title.style, { fontWeight: '700' });

    const filters = document.createElement('div');
    Object.assign(filters.style, { display: 'flex', gap: '8px', flexWrap: 'wrap' });

    const idInput = document.createElement('input');
    idInput.type = 'text';
    idInput.placeholder = 'Post ID...';
    Object.assign(idInput.style, {
      flex: '1',
      minWidth: '200px',
      padding: '8px 10px',
      borderRadius: '10px',
      border: '1px solid #374151',
      background: '#0b1220',
      color: '#e5e7eb',
      outline: 'none'
    });

    const dateInput = document.createElement('input');
    dateInput.type = 'date';
    Object.assign(dateInput.style, {
      flex: '1',
      minWidth: '200px',
      padding: '8px 10px',
      borderRadius: '10px',
      border: '1px solid #374151',
      background: '#0b1220',
      color: '#e5e7eb',
      outline: 'none'
    });

    filters.append(idInput, dateInput);
    left.append(title, filters);

    const hClose = document.createElement('button');
    hClose.type = 'button';
    hClose.textContent = '✕';
    Object.assign(hClose.style, {
      width: '32px',
      height: '32px',
      borderRadius: '8px',
      border: '1px solid #374151',
      background: '#1f2937',
      color: '#e5e7eb',
      cursor: 'pointer',
      fontSize: '16px'
    });

    head.append(left, hClose);

    const body = document.createElement('div');
    Object.assign(body.style, { padding: '10px 12px', overflowY: 'auto' });

    const actions = document.createElement('div');
    Object.assign(actions.style, {
      padding: '10px 12px',
      borderTop: '1px solid #1f2937',
      display: 'flex',
      justifyContent: 'flex-end',
      gap: '8px',
      background: '#111827'
    });

    const clearBtn = document.createElement('button');
    clearBtn.type = 'button';
    clearBtn.textContent = 'Clear';
    Object.assign(clearBtn.style, {
      padding: '8px 10px',
      borderRadius: '8px',
      border: '1px solid #7f1d1d',
      background: '#b91c1c',
      color: '#fff',
      cursor: 'pointer'
    });

    actions.append(clearBtn);

    let copiedWin = null;
    let confirmWin = null;

    function passesFilters(item) {
      const idq = (idInput.value || '').trim();
      const dq = (dateInput.value || '').trim();
      if (idq && !String(item.postId || '').includes(idq)) return false;
      if (dq && ymd(item.ts) !== dq) return false;
      return true;
    }

    function render() {
      body.innerHTML = '';

      let hist = loadHistory().filter(Boolean);
      hist.sort((a, b) => {
        const pa = a.pinned ? 1 : 0;
        const pb = b.pinned ? 1 : 0;
        if (pb !== pa) return pb - pa;
        return (b.ts || 0) - (a.ts || 0);
      });

      hist = hist.filter(passesFilters);

      if (!hist.length) {
        const empty = document.createElement('div');
        empty.textContent = 'No matches.';
        Object.assign(empty.style, { opacity: '0.8', padding: '8px 2px' });
        body.appendChild(empty);
        return;
      }

      for (const item of hist) {
        const row = document.createElement('div');
        row.title = 'Click to open';
        Object.assign(row.style, {
          display: 'grid',
          gridTemplateColumns: '64px 1fr auto',
          alignItems: 'center',
          gap: '12px',
          padding: '8px 10px',
          border: '1px solid #1f2937',
          borderRadius: '10px',
          marginBottom: '8px',
          background: item.pinned ? 'rgba(29,78,216,0.10)' : 'rgba(17,24,39,0.65)',
          cursor: 'pointer',
          transition: 'filter 120ms ease'
        });

        row.addEventListener('mouseenter', () => { row.style.filter = 'brightness(1.10)'; });
        row.addEventListener('mouseleave', () => { row.style.filter = ''; });

        const thumbWrap = document.createElement('div');
        Object.assign(thumbWrap.style, {
          width: '64px',
          height: '64px',
          borderRadius: '10px',
          overflow: 'hidden',
          border: '1px solid #1f2937',
          background: 'rgba(0,0,0,0.2)',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center'
        });

        if (item.previewUrl) {
          const img = document.createElement('img');
          img.src = item.previewUrl;
          img.alt = `Post ${item.postId}`;
          img.loading = 'lazy';
          Object.assign(img.style, { width: '100%', height: '100%', objectFit: 'cover', display: 'block' });
          thumbWrap.appendChild(img);
        } else {
          const no = document.createElement('div');
          no.textContent = '—';
          Object.assign(no.style, { opacity: 0.6, fontSize: '18px' });
          thumbWrap.appendChild(no);
        }

        const mid = document.createElement('div');
        Object.assign(mid.style, { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 });

        const top = document.createElement('div');
        top.textContent = `Post #${item.postId}`;
        Object.assign(top.style, { fontWeight: '700' });

        const subRow = document.createElement('div');
        Object.assign(subRow.style, { display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 });

        const sub = document.createElement('div');
        const copiedInfo = item.copiedCount ? ` • copied ${item.copiedCount}` : '';
        sub.textContent = `${formatTime(item.ts)}${copiedInfo}`;
        Object.assign(sub.style, {
          fontSize: '12px',
          opacity: '0.8',
          whiteSpace: 'nowrap',
          overflow: 'hidden',
          textOverflow: 'ellipsis',
          minWidth: 0
        });

        const starBtn = document.createElement('button');
        starBtn.type = 'button';
        starBtn.textContent = item.pinned ? '★' : '☆';
        starBtn.title = item.pinned ? 'Unpin' : 'Pin';
        Object.assign(starBtn.style, {
          border: 'none',
          background: 'transparent',
          cursor: 'pointer',
          fontSize: '24px',
          lineHeight: '1',
          padding: '0',
          margin: '0',
          color: item.pinned ? '#fbbf24' : 'rgba(229,231,235,0.85)',
          transform: 'translateY(-1px)'
        });

        starBtn.onmouseenter = () => { starBtn.style.filter = 'brightness(1.15)'; };
        starBtn.onmouseleave = () => { starBtn.style.filter = ''; };
        starBtn.onclick = (e) => {
          e.stopPropagation();
          setHistoryPinned(item.postId, !item.pinned);
          render();
        };

        subRow.append(sub, starBtn);
        mid.append(top, subRow);

        const right = document.createElement('div');
        Object.assign(right.style, { display: 'flex', gap: '8px', alignItems: 'center' });

        const showBtn = document.createElement('button');
        showBtn.type = 'button';
        showBtn.textContent = 'Show copied tags';
        Object.assign(showBtn.style, {
          padding: '6px 10px',
          borderRadius: '8px',
          border: '1px solid #1d4ed8',
          background: '#2563eb',
          color: '#fff',
          cursor: 'pointer'
        });

        const delBtn = document.createElement('button');
        delBtn.type = 'button';
        delBtn.textContent = 'Remove';
        Object.assign(delBtn.style, {
          padding: '6px 10px',
          borderRadius: '8px',
          border: '1px solid #7f1d1d',
          background: '#b91c1c',
          color: '#fff',
          cursor: 'pointer'
        });

        row.onclick = () => {
          const url = item.url || (`/posts/${item.postId}`);
          window.open(url, '_blank', 'noopener,noreferrer');
        };

        showBtn.onclick = (e) => {
          e.stopPropagation();
          if (copiedWin) return;
          const latest = loadHistory().find(x => String(x.postId) === String(item.postId)) || item;
          const txt = latest.copiedText || '';
          if (!txt) { makeToast('No copied tags saved'); return; }
          copiedWin = makeCopiedTagsWindow({
            overlay,
            text: txt,
            onClose: () => { copiedWin = null; }
          });
        };

        delBtn.onclick = (e) => {
          e.stopPropagation();
          saveHistory(loadHistory().filter(x => String(x?.postId) !== String(item.postId)));
          render();
        };

        right.append(showBtn, delBtn);
        row.append(thumbWrap, mid, right);
        body.appendChild(row);
      }
    }

    idInput.addEventListener('input', render);
    dateInput.addEventListener('change', render);

    clearBtn.onclick = () => {
      if (confirmWin) return;
      confirmWin = makeConfirmWindow({
        overlay,
        title: 'Clear history?',
        message: 'This will remove all entries exept your favourites.',
        yesText: 'Confirm',
        cancelText: 'Cancel',
        onYes: () => {
          clearHistoryNonPinned();
          render();
          makeToast('Cleared');
        },
        onClose: () => { confirmWin = null; }
      });
    };

    function close() {
      try { confirmWin?.close?.(); } catch {}
      confirmWin = null;
      try { copiedWin?.close?.(); } catch {}
      copiedWin = null;
      panel.remove();
      onCloseEscChange(true);
      onClose?.();
    }

    hClose.onclick = close;

    onCloseEscChange(false);

    panel.appendChild(box);
    box.append(head, body, actions);
    overlay.appendChild(panel);
    render();

    return { close };
  }

  function makeModal(tagsInfo) {
    const prefs = loadPrefs();
    const postId = tagsInfo.postId || getCurrentPostId();

    if (postId) {
      upsertHistoryEntry({
        postId,
        url: location.href,
        ts: Date.now(),
        previewUrl: tagsInfo.previewUrl || null
      });
    }

    const overlay = document.createElement('div');
    overlay.id = MODAL_ID;
    Object.assign(overlay.style, {
      position: 'fixed',
      inset: '0',
      background: 'rgba(0,0,0,0.45)',
      zIndex: 2147483647,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center'
    });

    const modal = document.createElement('div');
    Object.assign(modal.style, {
      background: '#111827',
      color: '#e5e7eb',
      width: 'min(980px, 94vw)',
      maxHeight: '88vh',
      overflow: 'hidden',
      borderRadius: '10px',
      boxShadow: '0 10px 30px rgba(0,0,0,0.5)',
      display: 'flex',
      flexDirection: 'column',
      border: '1px solid #1f2937'
    });

    const header = document.createElement('div');
    Object.assign(header.style, {
      padding: '12px 16px',
      background: '#0b1220',
      fontWeight: '600',
      position: 'sticky',
      top: '0',
      zIndex: '2',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'space-between',
      gap: '12px'
    });

    const title = document.createElement('div');
    title.textContent = 'e621 Tag Extractor';

    const headerRight = document.createElement('div');
    Object.assign(headerRight.style, { display: 'flex', alignItems: 'center', gap: '10px' });

    const counter = document.createElement('div');
    counter.textContent = 'Selected: 0 / 0';
    Object.assign(counter.style, { fontSize: '12px', opacity: '0.9', fontWeight: '500' });

    const closeX = document.createElement('button');
    closeX.type = 'button';
    closeX.textContent = '✕';
    closeX.title = 'Close';
    Object.assign(closeX.style, {
      width: '32px',
      height: '32px',
      borderRadius: '8px',
      border: '1px solid #374151',
      background: '#1f2937',
      color: '#e5e7eb',
      cursor: 'pointer',
      fontSize: '16px',
      lineHeight: '30px'
    });

    headerRight.append(counter, closeX);
    header.append(title, headerRight);

    const subline = document.createElement('div');
    subline.textContent = 'Check or uncheck the tags below to include or exclude them before copying:';
    Object.assign(subline.style, {
      padding: '8px 16px',
      fontSize: '13px',
      opacity: '0.8',
      borderBottom: '1px solid #1f2937'
    });

    const content = document.createElement('div');
    Object.assign(content.style, {
      display: 'grid',
      gridTemplateColumns: '320px 1fr',
      gap: '12px',
      padding: '12px 16px',
      overflow: 'hidden'
    });

    const controls = document.createElement('div');
    Object.assign(controls.style, { position: 'sticky', top: '56px', alignSelf: 'start' });

    controls.innerHTML = `
      <style>
        #${MODAL_ID} label { white-space: normal; word-break: break-word; display: block; line-height: 1.4; }
        #${MODAL_ID} input[type=radio], #${MODAL_ID} input[type=checkbox] { margin-right:6px; }
        #${MODAL_ID} .tx { width: 100%; box-sizing: border-box; padding: 8px 10px; border-radius: 8px; border: 1px solid #374151; background:#0b1220; color:#e5e7eb; }
        #${MODAL_ID} .btn { padding: 8px 10px; border-radius: 8px; border: 1px solid #374151; background: #1f2937; color: #e5e7eb; cursor:pointer; }
        #${MODAL_ID} .btn:hover { filter: brightness(1.07); }
        #${MODAL_ID} .btnPrimary { background:#2563eb; border-color:#1d4ed8; }
        #${MODAL_ID} .btnRow { display:flex; gap:8px; flex-wrap:wrap; align-items:center; justify-content:flex-start; }
        #${MODAL_ID} .section { border: 1px solid #1f2937; border-radius: 10px; padding: 10px; background: rgba(11,18,32,0.55); margin-bottom: 10px; }
        #${MODAL_ID} summary { user-select:none; }
        #${MODAL_ID} .rowHidden { display:none !important; }
        #${MODAL_ID} .tagRow:hover { background: rgba(255,255,255,0.04); border-radius: 6px; }
        #${MODAL_ID} .tagRow { padding: 2px 6px; }
        #${MODAL_ID} .small { font-size: 12px; opacity: 0.9; }
        #${MODAL_ID} .blWrap { position: relative; }
        #${MODAL_ID} .suggDropdown {
          position: absolute;
          left: 0;
          right: 0;
          top: calc(100% + 6px);
          border: 1px solid #1f2937;
          border-radius: 10px;
          background: rgba(11,18,32,0.98);
          max-height: 180px;
          overflow-y: auto;
          box-shadow: 0 10px 24px rgba(0,0,0,0.45);
          display: none;
          z-index: 3;
        }
        #${MODAL_ID} .suggItem {
          padding: 8px 10px;
          cursor: pointer;
          font-size: 13px;
          border-bottom: 1px solid rgba(31,41,55,0.6);
        }
        #${MODAL_ID} .suggItem:last-child { border-bottom: none; }
        #${MODAL_ID} .suggItem:hover { filter: brightness(1.12); background: rgba(255,255,255,0.04); }
        #${MODAL_ID} .suggEmpty { padding: 10px; font-size: 12px; opacity: 0.75; }

        #${MODAL_ID} * { scrollbar-width: thin; scrollbar-color: #1d4ed8 rgba(11,18,32,0.70); }
        #${MODAL_ID} *::-webkit-scrollbar { width: 10px; height: 10px; }
        #${MODAL_ID} *::-webkit-scrollbar-track { background: rgba(11,18,32,0.70); border-radius: 999px; }
        #${MODAL_ID} *::-webkit-scrollbar-thumb { background: rgba(29,78,216,0.75); border-radius: 999px; border: 2px solid rgba(11,18,32,0.70); }
        #${MODAL_ID} *::-webkit-scrollbar-thumb:hover { background: rgba(29,78,216,0.95); }
        #${MODAL_ID} *::-webkit-scrollbar-corner { background: rgba(11,18,32,0.70); }
      </style>

      <div class="section">
        <input class="tx" type="text" name="searchText" placeholder="Search..." />
      </div>

      <div class="section">
        <div style="font-weight:600;margin-bottom:8px">Blacklist</div>

        <label class="small" style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
          <input type="checkbox" name="autoApplyBlacklist" />
          Auto-apply
        </label>

        <div class="blWrap">
          <textarea class="tx" name="blacklist" rows="5" placeholder="One per line or comma-separated"></textarea>
          <div class="suggDropdown" data-suggestions></div>
        </div>

        <div class="btnRow" style="margin-top:10px">
          <button type="button" class="btn btnPrimary" data-act="applyRules" title="Enter also applies">Apply</button>
        </div>
      </div>

      <div class="section">
        <div style="font-weight:600;margin-bottom:8px">Format</div>
        <label><input type="radio" name="wordStyle" value="underscores"> Keep underscores</label>
        <label><input type="radio" name="wordStyle" value="spaces"> Replace underscores with spaces</label>
      </div>

      <div class="section">
        <div style="font-weight:600;margin-bottom:8px">Groups</div>
        ${['general','species','character','artist','copyright','meta','lore','invalid'].map(g=>`
          <label><input type="checkbox" name="grp" value="${g}" checked> ${g}</label>
        `).join('')}
      </div>
    `;

    const rightCol = document.createElement('div');
    Object.assign(rightCol.style, {
      maxHeight: '74vh',
      overflowY: 'auto',
      border: '1px solid #1f2937',
      borderRadius: '8px',
      padding: '8px',
      background: 'rgba(11,18,32,0.25)'
    });

    const footer = document.createElement('div');
    Object.assign(footer.style, {
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'space-between',
      gap: '10px',
      padding: '12px 16px',
      background: '#0b1220',
      borderTop: '1px solid #1f2937',
      position: 'sticky',
      bottom: '0',
      zIndex: '2'
    });

    const leftBtns = document.createElement('div');
    Object.assign(leftBtns.style, { display: 'flex', gap: '10px', alignItems: 'center' });

    const historyBtn = document.createElement('button');
    historyBtn.type = 'button';
    historyBtn.textContent = 'History';
    Object.assign(historyBtn.style, {
      padding: '8px 12px',
      borderRadius: '8px',
      border: '1px solid #374151',
      background: '#1f2937',
      color: '#e5e7eb',
      cursor: 'pointer'
    });

    const resetAllBtn = document.createElement('button');
    resetAllBtn.type = 'button';
    resetAllBtn.textContent = 'Reset';
    Object.assign(resetAllBtn.style, {
      padding: '8px 12px',
      borderRadius: '8px',
      border: '1px solid #374151',
      background: '#1f2937',
      color: '#e5e7eb',
      cursor: 'pointer'
    });

    leftBtns.append(historyBtn, resetAllBtn);

    const copyBtn = document.createElement('button');
    copyBtn.type = 'button';
    copyBtn.textContent = 'Copy';
    Object.assign(copyBtn.style, {
      padding: '8px 12px',
      borderRadius: '8px',
      border: '1px solid #1d4ed8',
      background: '#2563eb',
      color: '#e5e7eb',
      cursor: 'pointer'
    });

    content.append(controls, rightCol);
    modal.append(header, subline, content, footer);
    footer.append(leftBtns, copyBtn);
    overlay.appendChild(modal);

    const { grouped, order } = tagsInfo;
    const allItems = [];
    const allTags = [];

    for (const g of order) {
      const tags = grouped[g] || [];
      if (!tags.length) continue;

      const details = document.createElement('details');
      details.open = true;
      details.style.marginBottom = '10px';

      const summary = document.createElement('summary');
      summary.textContent = `${g} (${tags.length})`;
      Object.assign(summary.style, { cursor: 'pointer', fontWeight: '600', padding: '4px 2px' });
      details.appendChild(summary);

      const section = document.createElement('div');
      section.style.paddingLeft = '6px';

      for (const t of tags) {
        allTags.push(t);

        const row = document.createElement('label');
        row.className = 'tagRow';
        Object.assign(row.style, {
          display: 'grid',
          gridTemplateColumns: 'auto 1fr',
          gap: '8px',
          alignItems: 'center'
        });
        row.dataset.group = g;
        row.dataset.tag = t;

        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.checked = true;

        const name = document.createElement('span');
        name.textContent = t;

        row.append(cb, name);
        section.appendChild(row);

        allItems.push({ row, cb, tag: t, group: g });
      }

      details.appendChild(section);
      rightCol.appendChild(details);
    }

    const searchInput = controls.querySelector('input[name="searchText"]');
    const blacklistTa = controls.querySelector('textarea[name="blacklist"]');
    const autoApplyCb = controls.querySelector('input[name="autoApplyBlacklist"]');
    const suggDropdown = controls.querySelector('[data-suggestions]');

    let confirmWin = null;
    let escClosesMain = true;
    let historyWin = null;

    const hideDropdown = () => { suggDropdown.style.display = 'none'; suggDropdown.innerHTML = ''; };
    const showDropdown = (nodesOrHtml) => {
      suggDropdown.style.display = 'block';
      if (typeof nodesOrHtml === 'string') {
        suggDropdown.innerHTML = nodesOrHtml;
      } else {
        suggDropdown.innerHTML = '';
        for (const n of nodesOrHtml) suggDropdown.appendChild(n);
      }
    };

    function currentPrefs() {
      const wordStyle = controls.querySelector('input[name="wordStyle"]:checked')?.value || 'underscores';
      const separator = DEFAULT_PREFS.separator;
      const preselectGroups = Array.from(controls.querySelectorAll('input[name="grp"]:checked')).map(i => i.value);
      return {
        wordStyle,
        separator,
        preselectGroups,
        searchText: searchInput.value || '',
        blacklist: blacklistTa.value || '',
        autoApplyBlacklist: !!autoApplyCb.checked
      };
    }

    function updateCounter() {
      const total = allItems.length;
      const selected = allItems.reduce((acc, it) => acc + (it.cb.checked ? 1 : 0), 0);
      counter.textContent = `Selected: ${selected} / ${total}`;
    }

    function applySearchFilter() {
      const q = (searchInput.value || '').trim().toLowerCase();
      for (const it of allItems) {
        const ok = !q || it.tag.toLowerCase().includes(q);
        it.row.classList.toggle('rowHidden', !ok);
      }
    }

    function getAllowedGroupsSet() {
      return new Set(Array.from(controls.querySelectorAll('input[name="grp"]:checked')).map(i => i.value));
    }

    function syncGroupChecks() {
      const allowed = getAllowedGroupsSet();
      for (const it of allItems) {
        if (!it.cb._touched) it.cb.checked = allowed.has(it.group);
      }
      updateCounter();
    }

    function applyRulesManual() {
      const p = currentPrefs();
      const black = parsePatterns(p.blacklist);
      if (!black.length) { makeToast('No blacklist patterns'); return; }

      let changed = 0;
      for (const it of allItems) {
        if (matchesAny(it.tag, black)) {
          if (it.cb.checked) changed++;
          it.cb.checked = false;
          it.cb._touched = true;
        }
      }
      updateCounter();
      makeToast(`Applied: ${changed}`);
    }

    function applyRulesAuto() {
      const p = currentPrefs();
      if (!p.autoApplyBlacklist) return;

      const black = parsePatterns(p.blacklist);
      const allowed = getAllowedGroupsSet();

      for (const it of allItems) {
        if (it.cb._touched) continue;
        const baseline = allowed.has(it.group);
        const blocked = black.length ? matchesAny(it.tag, black) : false;
        it.cb.checked = baseline && !blocked;
      }
      updateCounter();
    }

    function clearManualOverrides() {
      for (const it of allItems) delete it.cb._touched;
    }

    function getActiveToken(text, caretPos) {
      const pos = typeof caretPos === 'number' ? caretPos : text.length;
      const before = text.slice(0, pos);
      const lastSep = Math.max(before.lastIndexOf('\n'), before.lastIndexOf(','));
      const tokenStart = lastSep === -1 ? 0 : lastSep + 1;
      const token = before.slice(tokenStart).trim();
      return { token, tokenStart, pos };
    }

    function updateSuggestions() {
      const { token } = getActiveToken(blacklistTa.value, blacklistTa.selectionStart);

      if (!token || token.startsWith('/')) {
        hideDropdown();
        return;
      }

      const q = token.toLowerCase();
      const matches = allTags.filter(t => t.toLowerCase().includes(q)).slice(0, 15);

      if (!matches.length) {
        showDropdown(`<div class="suggEmpty">No matches</div>`);
        return;
      }

      const nodes = matches.map(t => {
        const div = document.createElement('div');
        div.className = 'suggItem';
        div.textContent = t;
        div.onclick = () => {
          const value = blacklistTa.value;
          const caret = blacklistTa.selectionStart;
          const { tokenStart, pos } = getActiveToken(value, caret);

          const before = value.slice(0, tokenStart);
          const after = value.slice(pos);
          blacklistTa.value = before + t + after;

          const newCaret = before.length + t.length;
          blacklistTa.focus();
          blacklistTa.setSelectionRange(newCaret, newCaret);

          savePrefs(currentPrefs());
          scheduleAutoApply();
          updateSuggestions();
        };
        return div;
      });

      showDropdown(nodes);
    }

    let autoTimer = null;
    function scheduleAutoApply() {
      clearTimeout(autoTimer);
      autoTimer = setTimeout(applyRulesAuto, 250);
    }

    function setEscMode(mainAllowed) { escClosesMain = mainAllowed; }

    function applyPrefsToUI(p) {
      searchInput.value = p.searchText || '';
      blacklistTa.value = p.blacklist || '';
      autoApplyCb.checked = p.autoApplyBlacklist !== false;

      const wsRadio = controls.querySelector(`input[name="wordStyle"][value="${p.wordStyle}"]`);
      if (wsRadio) wsRadio.checked = true;

      const wanted = new Set(p.preselectGroups || DEFAULT_PREFS.preselectGroups);
      for (const gc of Array.from(controls.querySelectorAll('input[name="grp"]'))) {
        gc.checked = wanted.has(gc.value);
      }
    }

    function resetEverythingExceptHistory() {
      hideDropdown();
      clearManualOverrides();
      applyPrefsToUI(DEFAULT_PREFS);
      applySearchFilter();
      syncGroupChecks();
      applyRulesAuto();
      updateCounter();
      savePrefs(currentPrefs());
      makeToast('Reset');
    }

    function closeModal() {
      overlay.remove();
      document.removeEventListener('keydown', onKeydown);
    }

    applyPrefsToUI(prefs);
    applySearchFilter();
    syncGroupChecks();
    applyRulesAuto();
    updateCounter();

    controls.addEventListener('input', (e) => {
      if (e.target === searchInput) applySearchFilter();

      if (e.target === searchInput || e.target === blacklistTa) {
        savePrefs(currentPrefs());
      }
      if (e.target === blacklistTa) {
        updateSuggestions();
        scheduleAutoApply();
      }
    });

    blacklistTa.addEventListener('focus', updateSuggestions);
    blacklistTa.addEventListener('click', updateSuggestions);
    blacklistTa.addEventListener('keyup', updateSuggestions);
    blacklistTa.addEventListener('blur', () => setTimeout(hideDropdown, 120));

    controls.addEventListener('change', (e) => {
      const t = e.target;
      if (!t) return;

      if (t.name === 'grp') {
        syncGroupChecks();
        applyRulesAuto();
      }
      if (t === autoApplyCb && autoApplyCb.checked) applyRulesAuto();

      savePrefs(currentPrefs());
    });

    controls.addEventListener('click', (e) => {
      const btn = e.target.closest('button');
      if (!btn) return;
      if (btn.getAttribute('data-act') === 'applyRules') {
        applyRulesManual();
        savePrefs(currentPrefs());
      }
    });

    rightCol.addEventListener('change', (e) => {
      const el = e.target;
      if (el && el.type === 'checkbox') {
        el._touched = true;
        updateCounter();
      }
    });

    resetAllBtn.onclick = () => {
      if (confirmWin) return;
      confirmWin = makeConfirmWindow({
        overlay,
        title: 'Are you sure you want to Reset?',
        message: '',
        yesText: 'Confirm',
        cancelText: 'Cancel',
        centerTitleOnly: true,
        onYes: resetEverythingExceptHistory,
        onClose: () => { confirmWin = null; }
      });
    };

    historyBtn.onclick = () => {
      if (historyWin) return;
      historyWin = makeHistoryWindow({
        overlay,
        onCloseEscChange: (mainAllowed) => setEscMode(mainAllowed),
        onClose: () => { historyWin = null; }
      });
    };

    function onKeydown(e) {
      if (e.key === 'Escape') {
        if (!escClosesMain) {
          historyWin?.close?.();
          return;
        }
        closeModal();
        return;
      }

      const active = document.activeElement;
      const tag = active?.tagName?.toLowerCase?.() || '';
      const isTyping = (tag === 'input' || tag === 'textarea');

      if (e.key === '/' && !isTyping && !e.ctrlKey && !e.metaKey && !e.altKey) {
        e.preventDefault();
        searchInput.focus();
        searchInput.select();
        return;
      }

      if (e.key === 'Enter' && active === blacklistTa) {
        e.preventDefault();
        applyRulesManual();
        savePrefs(currentPrefs());
      }
    }

    document.addEventListener('keydown', onKeydown);
    closeX.onclick = closeModal;

    copyBtn.onclick = async () => {
      const prefsNow = currentPrefs();
      savePrefs(prefsNow);

      let selected = allItems.filter(it => it.cb.checked).map(it => it.tag);
      if (prefsNow.wordStyle === 'spaces') selected = selected.map(t => t.replace(/_/g, ' '));

      const text = selected.join(prefsNow.separator);
      if (!text) return;

      await copyToClipboard(text);
      makeToast(`Copied: ${selected.length}`);

      if (postId) updateHistoryCopied(postId, text, selected.length);
    };

    return overlay;
  }

  async function handleClick() {
    const info = await getTagsFromAPI();

    if (!info.flat.length) {
      const anchors = document.querySelectorAll('a.search-tag, aside a[href^="/posts?tags="]');
      const tags = Array.from(anchors).map(a => (a.textContent || '').trim()).filter(Boolean);

      info.grouped = { general: tags };
      info.order = ['general'];
      info.flat = unique(tags);
      info.postId = getCurrentPostId();
      info.previewUrl = null;
    }

    if (!info.flat.length) return;
    document.body.appendChild(makeModal(info));
  }

  function addButtonOnce() {
    if (document.getElementById(BTN_ID)) return;
    const btn = buildButton();
    btn.addEventListener('click', handleClick);
    document.body.appendChild(btn);
  }

  function init() {
    if (document.readyState === 'complete' || document.readyState === 'interactive') addButtonOnce();
    else document.addEventListener('DOMContentLoaded', addButtonOnce, { once: true });

    const mo = new MutationObserver(() => addButtonOnce());
    mo.observe(document.documentElement, { childList: true, subtree: true });
  }

  init();
})();