e621 Tag Extract - API solid

Extract tags from e621 images

Per 26-08-2025. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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