e621 Tag Extract - API solid

Extract tags from e621 images

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

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