e621 Tag Extractor 2.0

Extract tags from e621.net

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         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();
})();