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