e621 Tag Extract - API solid

Extract tags from e621 images

За да инсталирате този скрипт, трябва да имате инсталирано разширение като 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 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();
})();