e621 Tag Extract - API solid

Extract tags from e621 images

// ==UserScript==
// @name         e621 Tag Extract - API solid
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  Extract tags from e621 images
// @author       cemtrex (partly with AI coding assistant)
// @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 = 'e621TagExtractPrefs_v2';

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

  const unique = (arr) => Array.from(new Set(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 {} };

  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 m = location.pathname.match(/\/posts\/(\d+)/);
    if (!m) return { flat: [], grouped: {}, order: [] };
    const postId = m[1];
    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: [] };
      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));
      return { flat, grouped, order };
    } catch (e) {
      console.error('[e621 Tag Extract] API error:', e);
      return { flat: [], grouped: {}, order: [] };
    }
  }

  function buildButton() {
    const button = document.createElement('button');
    button.id = BTN_ID;
    button.type = 'button';
    button.textContent = '+';
    button.title = 'Extract, review, and copy tags';
    button.setAttribute('aria-label', 'Extract, 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 makeModal(tagsInfo) {
    const prefs = loadPrefs();

    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(920px, 94vw)', maxHeight: '86vh', 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');
    header.textContent = 'e621 Tag Extract';
    Object.assign(header.style, { padding: '12px 16px', background: '#0b1220', fontWeight: '600', position:'sticky', top:'0', zIndex:'2' });

    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: '300px 1fr', gap: '12px', padding: '12px 16px', overflow: 'hidden' });

    const controls = document.createElement('div');
    Object.assign(controls.style, { position: 'sticky', top: '48px', 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:4px; }
      </style>
      <div style="margin-bottom:10px">
        <div style="font-weight:600;margin-bottom:6px">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 style="margin:10px 0">
        <div style="font-weight:600;margin-bottom:6px">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: '72vh', overflowY: 'auto', border: '1px solid #1f2937', borderRadius: '8px', padding: '8px' });

    const footer = document.createElement('div');
    Object.assign(footer.style, { display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '10px', padding: '12px 16px', background: '#0b1220', borderTop: '1px solid #1f2937', position:'sticky', bottom:'0', zIndex:'2' });
    const cancelBtn = document.createElement('button'); cancelBtn.textContent = 'Cancel';
    const copyBtn = document.createElement('button'); copyBtn.textContent = 'Copy';
    for (const b of [cancelBtn, copyBtn]) Object.assign(b.style, { padding: '8px 12px', borderRadius: '8px', border: '1px solid #374151', background: '#1f2937', color: '#e5e7eb', cursor: 'pointer' });
    Object.assign(copyBtn.style, { background: '#2563eb', borderColor: '#1d4ed8' });

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

    const { grouped, order } = tagsInfo;
    const allItems = [];
    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 = '8px';
      for (const t of tags) {
        const row = document.createElement('label');
        Object.assign(row.style, { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px', alignItems: 'center', padding: '2px 0' });
        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);
      }
      details.appendChild(section);
      rightCol.appendChild(details);
    }

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

    function syncGroupChecks() {
      const allowed = new Set(Array.from(controls.querySelectorAll('input[name="grp"]:checked')).map(i=>i.value));
      for (const r of allItems) {
        const cb = r.querySelector('input[type="checkbox"]');
        if (!cb._touched) cb.checked = allowed.has(r.dataset.group);
      }
    }
    controls.addEventListener('change', (e)=>{
      if (e.target && e.target.name === 'grp') syncGroupChecks();
      savePrefs(currentPrefs());
    });

    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 };
    }

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

    cancelBtn.onclick = () => overlay.remove();
    overlay.addEventListener('click', (e)=>{ if (e.target === overlay) overlay.remove(); });
    document.addEventListener('keydown', function esc(e){ if (e.key==='Escape'){ overlay.remove(); document.removeEventListener('keydown', esc); } });

    copyBtn.onclick = async () => {
      const prefsNow = currentPrefs(); savePrefs(prefsNow);
      let selected = allItems.filter(r=>r.querySelector('input').checked).map(r=>r.dataset.tag);
      if (prefsNow.wordStyle === 'spaces') selected = selected.map(t=>t.replace(/_/g,' '));
      const text = selected.join(prefsNow.separator);
      if (!text) { overlay.remove(); return; }
      await copyToClipboard(text);
      overlay.remove();
      console.log('[e621 Tag Extract] Copied', selected.length, 'tags');
    };

    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);
    }
    if (!info.flat.length) return;
    const modal = makeModal(info);
    document.body.appendChild(modal);
  }

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