Copy all tags from a Sankaku Complex post page and paste them into the upload page tag field. Tries to reuse the site's own tag objects so tags keep their references; falls back to raw text paste.
// ==UserScript==
// @name Sankaku Tag Copy & Paste
// @namespace sankaku-tagging-tool
// @version 1.9.2
// @description Copy all tags from a Sankaku Complex post page and paste them into the upload page tag field. Tries to reuse the site's own tag objects so tags keep their references; falls back to raw text paste.
// @match *://sankakucomplex.com/*
// @match *://*.sankakucomplex.com/*
// @match *://sankaku.app/*
// @match *://*.sankaku.app/*
// @run-at document-start
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_setClipboard
// @grant unsafeWindow
// @license MIT
// @noframes
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'skk_tag_bundle_v1';
const BTN_ATTR = 'data-skk-btn';
let copyBusy = false;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const safeDecode = (value) => {
try { return decodeURIComponent(value); } catch (e) { return value; }
};
// Match display spaces to slug underscores without merging punctuation or Unicode tags.
const normKey = (value) => String(value || '').trim().toLowerCase().replace(/\s+/g, '_');
const store = {
read() {
try {
if (typeof GM_getValue === 'function') {
const value = GM_getValue(STORAGE_KEY, null);
if (value && typeof value === 'object' && Array.isArray(value.tags)) return value;
}
} catch (e) {}
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) { return null; }
},
write(value) {
try {
if (typeof GM_setValue === 'function') GM_setValue(STORAGE_KEY, value);
} catch (e) {}
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); } catch (e) {}
}
};
async function copyToClipboard(text) {
try {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text, 'text');
return true;
}
} catch (e) {}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (e) {}
try {
const area = document.createElement('textarea');
area.value = text;
area.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
(document.body || document.documentElement).appendChild(area);
area.select();
const ok = document.execCommand('copy');
area.remove();
return ok;
} catch (e) { return false; }
}
async function readClipboardText() {
try {
if (navigator.clipboard && navigator.clipboard.readText) return await navigator.clipboard.readText();
} catch (e) {}
return '';
}
const capturedResponses = [];
let captureVersion = 0;
let pendingTagRequests = 0;
let tagResponseVersion = 0;
const unrelatedPostData = /related|similar|recommend|comments?|reactions?|votes?|ratings?|favorites?|users?/i;
const unrelatedPostUrl = (url) => /related|similar|recommend|comments?/i.test(url) ||
/\/(?:reactions?|votes?|ratings?|favorites?|users?)(?:[/?#]|$)/i.test(url);
const isTagRequest = (url) => /\/tags?(?:[/?#]|$)|\/posts?(?:[/?#]|$)/i.test(String(url || '')) && !unrelatedPostUrl(String(url || ''));
function rememberResponse(url, text) {
if (!text || text.length > 5000000) return;
const head = text.trimStart().charAt(0);
if (head !== '{' && head !== '[') return;
let json = null;
try { json = JSON.parse(text); } catch (e) { return; }
rememberJsonResponse(url, json);
}
function rememberJsonResponse(url, json) {
if (!json || typeof json !== 'object') return;
captureVersion += 1;
if (isTagRequest(url)) tagResponseVersion += 1;
const lists = [];
try { scanForTagLists(json, lists); } catch (e) { return; }
const entries = new WeakMap();
const compact = [];
for (const list of lists) {
const tags = list.tags.map((item) => createTagEntry(item, entries)).filter(Boolean);
if (tags.length) compact.push({
id: list.id && typeof list.id === 'object' ? String(list.id) : list.id,
contextId: list.contextId && typeof list.contextId === 'object' ? String(list.contextId) : list.contextId,
declared: list.declared,
fromArray: list.fromArray, tags,
});
}
if (!compact.length) return;
// Keep the matching metadata and complete tag objects, never the response envelope.
capturedResponses.push({ url: String(url || ''), lists: compact,
total: !Array.isArray(json) && Number.isInteger(json.total) ? json.total : 0 });
if (capturedResponses.length > 40) capturedResponses.shift();
}
function installNetworkCapture() {
try {
const pageWindow = typeof unsafeWindow !== 'undefined' && unsafeWindow ? unsafeWindow : window;
const origFetch = pageWindow.fetch;
if (typeof origFetch === 'function') {
pageWindow.fetch = function () {
const args = arguments;
const input = args[0];
const url = typeof input === 'string' ? input : input && input.url ? input.url : '';
const relevant = isTagRequest(url);
if (relevant) pendingTagRequests += 1;
let finished = false;
const done = () => {
if (finished) return;
finished = true;
if (relevant) pendingTagRequests -= 1;
};
let result;
try { result = origFetch.apply(this, args); } catch (e) { done(); throw e; }
if (result && typeof result.then === 'function') {
result.then((response) => {
try {
const type = response && response.headers ? response.headers.get('content-type') || '' : '';
if (response && response.ok && /json/i.test(type)) {
return response.clone().text().then((text) => rememberResponse(url, text)).catch(() => {}).then(done);
}
} catch (e) {}
done();
}, done);
} else done();
return result;
};
}
const XHR = pageWindow.XMLHttpRequest;
if (XHR && XHR.prototype) {
const origOpen = XHR.prototype.open;
const origSend = XHR.prototype.send;
XHR.prototype.open = function (method, url) {
try { this.__skkUrl = url; } catch (e) {}
return origOpen.apply(this, arguments);
};
XHR.prototype.send = function () {
const xhr = this;
const relevant = isTagRequest(this.__skkUrl);
if (relevant) pendingTagRequests += 1;
let finished = false;
const done = () => {
if (finished) return;
finished = true;
if (relevant) pendingTagRequests -= 1;
try {
xhr.removeEventListener('load', onLoad);
xhr.removeEventListener('loadend', done);
} catch (e) {}
};
const onLoad = function () {
try {
if (this.responseType === '' || this.responseType === 'text') {
rememberResponse(this.__skkUrl, this.responseText);
} else if (this.responseType === 'json') {
rememberJsonResponse(this.__skkUrl, this.response);
}
} catch (e) {}
};
try {
this.addEventListener('loadend', done, { once: true });
this.addEventListener('load', onLoad, { once: true });
} catch (e) { done(); }
try { return origSend.apply(this, arguments); } catch (e) { done(); throw e; }
};
}
} catch (e) {}
}
installNetworkCapture();
function getPostId() {
const path = location.pathname || '';
const match = path.match(/\/post\/show\/([^/?#]+)/i) || path.match(/\/posts?\/([^/?#]+)/i);
if (match) return safeDecode(match[1]);
const queryId = new URLSearchParams(location.search).get('id');
return queryId || null;
}
function readTagFields(value) {
if (!value || typeof value !== 'object') return { slug: '', name: '' };
const pick = (keys) => {
for (const key of keys) {
const candidate = value[key];
if (typeof candidate === 'string' && candidate) return candidate;
}
return '';
};
const slug = pick(['name', 'tag_name', 'tagName', 'slug', 'name_en']);
const name = pick(['name_en', 'name', 'tag_name', 'tagName', 'slug']);
return { slug, name };
}
function isTagRecord(value) {
if (typeof value === 'string') return value.length > 0;
if (!value || typeof value !== 'object') return false;
const fields = readTagFields(value);
return !!(fields.slug || fields.name);
}
function looksLikeTagItem(value) {
if (typeof value === 'string') return value.length > 0 && value.length < 100;
if (!value || typeof value !== 'object') return false;
const fields = readTagFields(value);
if (!fields.slug && !fields.name) return false;
return 'id' in value || 'type' in value || 'tag_type' in value || 'name_en' in value;
}
function scanForTagLists(node, lists) {
const queue = [{ value: node, depth: 0, parentId: null, parentDeclared: 0, collectionEnvelope: true }];
const seen = new Set();
let visited = 0;
for (let cursor = 0; cursor < queue.length && visited < 20000 && lists.length < 12; cursor += 1) {
const entry = queue[cursor];
queue[cursor] = null;
visited += 1;
const value = entry.value;
if (!value || typeof value !== 'object' || seen.has(value) || entry.depth > 8) continue;
seen.add(value);
if (Array.isArray(value)) {
// A named object with an ID may be a reaction or user. Only collection
// envelopes can be anonymous tag lists; other arrays require a `tags` key.
if (entry.collectionEnvelope && value.length && value.length <= 500 && value.every(looksLikeTagItem)) {
lists.push({
tags: value,
id: null,
contextId: entry.parentId,
declared: entry.parentDeclared || 0,
fromArray: true,
});
}
for (const child of value) queue.push({ value: child, depth: entry.depth + 1, parentId: entry.parentId, parentDeclared: entry.parentDeclared });
continue;
}
const ownId = value.id !== undefined ? value.id : (value.post_id !== undefined ? value.post_id : (value.postId !== undefined ? value.postId : null));
const contextId = ownId !== null ? ownId : entry.parentId;
let ownDeclared = entry.parentDeclared || 0;
// A generic `total` can count posts, search results, or a tag's uses.
for (const field of ['tag_count', 'tags_count', 'total_tag_count', 'tagcount', 'totalTags']) {
const candidate = value[field];
if (typeof candidate === 'number' && candidate > ownDeclared) ownDeclared = candidate;
}
if (
Array.isArray(value.tags) &&
value.tags.length &&
value.tags.every((t) => isTagRecord(t) || (t && typeof t === 'object' && isTagRecord(t.tag)))
) {
lists.push({ tags: value.tags, id: ownId, contextId, declared: ownDeclared, fromArray: false });
}
for (const key of Object.keys(value)) {
const child = value[key];
if (unrelatedPostData.test(key)) continue;
if (child && typeof child === 'object') queue.push({ value: child, depth: entry.depth + 1, parentId: contextId, parentDeclared: ownDeclared,
collectionEnvelope: entry.collectionEnvelope && /^(data|results|tags)$/.test(key) });
}
}
}
function createTagEntry(item, entries) {
const tagObj = extractTagFromItem(item);
if (!tagObj) return null;
if (entries && entries.has(tagObj)) return entries.get(tagObj);
const fields = readTagFields(tagObj);
const slug = fields.slug || fields.name;
const name = fields.name || fields.slug;
const key = normKey(slug) || normKey(name);
if (!key) return null;
const raw = sanitizeValue(tagObj, 0, new Set());
const entry = { slug, name, raw: hasTagData(raw) ? raw : null };
if (entries) entries.set(tagObj, entry);
return entry;
}
function addTagRecordToMap(map, item, entries) {
const entry = createTagEntry(item, entries);
if (entry) mergeTagEntries([entry], map);
}
function findPostTagsFromApi(postId, wanted = new Set()) {
if (!postId || !capturedResponses.length) return null;
const merged = new Map();
let declared = 0;
for (const response of capturedResponses) {
const url = typeof response.url === 'string' ? response.url : '';
let urlHasPostId = false;
try {
const parsed = new URL(url, location.href);
urlHasPostId = parsed.pathname.split('/').includes(postId) ||
['post_id', 'postId', 'post', 'id'].some((key) => parsed.searchParams.get(key) === postId);
} catch (e) {}
if (unrelatedPostUrl(url)) continue;
const urlIsPostEndpoint = urlHasPostId && /\/posts?[/?]/i.test(url);
const urlIsTagsEndpoint = /\/tags?(?:[/?#]|$)/i.test(url);
for (const list of response.lists) {
const idMatch = String(list.id) === postId || String(list.contextId) === postId;
// Post responses authorize explicit tags fields, not arbitrary arrays.
const associated = list.fromArray
? urlIsTagsEndpoint && urlHasPostId
: idMatch || urlIsPostEndpoint || (urlIsTagsEndpoint && urlHasPostId);
const accept = associated || urlIsTagsEndpoint;
if (!accept) continue;
if (associated && list.declared > declared) declared = list.declared;
// `total` is authoritative only for a tag collection scoped to this post.
if (urlIsTagsEndpoint && urlHasPostId) {
const total = response.total;
if (Number.isInteger(total) && total > declared) declared = total;
}
for (const tag of list.tags) {
if (associated || wanted.has(normKey(tag.slug)) || wanted.has(normKey(tag.name))) {
mergeTagEntries([tag], merged);
}
}
}
}
if (!merged.size) return null;
return { tags: Array.from(merged.values()), declared };
}
function getFiber(element) {
if (!element) return null;
for (const name of Object.keys(element)) {
if (name.startsWith('__reactFiber$') || name.startsWith('__reactInternalInstance$')) {
const fiber = element[name];
let root = fiber;
for (let level = 0; root && root.return && level < 64; level += 1) root = root.return;
// React may leave the DOM pointing at the previous alternate after a commit.
const current = root && root.stateNode && root.stateNode.current;
return current && current !== root && fiber.alternate ? fiber.alternate : fiber;
}
}
return null;
}
function looksLikeTagObject(value, wanted) {
if (!value || typeof value !== 'object') return false;
const fields = readTagFields(value);
return (fields.slug && wanted.has(normKey(fields.slug))) || (fields.name && wanted.has(normKey(fields.name)));
}
function scoreTagObject(value) {
let score = 0;
if ('id' in value) score += 4;
if (typeof value.name === 'string') score += 2;
if (typeof value.name_en === 'string') score += 1;
if (typeof value.type === 'string') score += 2;
score += Math.min(Object.keys(value).length, 8) / 8;
return score;
}
function findTagObjectIn(value, wanted, depth, seen, out) {
if (!value || typeof value !== 'object' || depth > 4 || out.length >= 10) return;
if (seen.has(value)) return;
seen.add(value);
if (Array.isArray(value)) {
for (const item of value) findTagObjectIn(item, wanted, depth + 1, seen, out);
return;
}
if (Object.prototype.toString.call(value) !== '[object Object]') return;
if (looksLikeTagObject(value, wanted)) {
out.push(value);
return;
}
for (const key of Object.keys(value)) {
const child = value[key];
if (child && typeof child === 'object') findTagObjectIn(child, wanted, depth + 1, seen, out);
}
}
function sanitizeValue(value, depth, seen) {
if (value === null) return null;
const type = typeof value;
if (type === 'string' || type === 'number' || type === 'boolean') return value;
if (type !== 'object' || depth > 5 || seen.has(value)) return undefined;
seen.add(value);
if (Array.isArray(value)) {
const out = [];
for (const item of value) {
const clean = sanitizeValue(item, depth + 1, seen);
if (clean !== undefined) out.push(clean);
}
return out;
}
if (Object.prototype.toString.call(value) !== '[object Object]') return undefined;
const out = {};
for (const key of Object.keys(value)) {
const clean = sanitizeValue(value[key], depth + 1, seen);
if (clean !== undefined) out[key] = clean;
}
return out;
}
function extractTagObject(anchor, slug, name) {
try {
const wanted = new Set([normKey(slug), normKey(name)].filter(Boolean));
if (!wanted.size) return null;
const candidates = [];
let fiber = getFiber(anchor);
for (let level = 0; fiber && level < 16 && candidates.length < 10; level += 1) {
if (fiber.memoizedProps) findTagObjectIn(fiber.memoizedProps, wanted, 0, new Set(), candidates);
fiber = fiber.return;
}
if (!candidates.length) return null;
candidates.sort((a, b) => scoreTagObject(b) - scoreTagObject(a));
const clean = sanitizeValue(candidates[0], 0, new Set());
return clean && typeof clean === 'object' ? clean : null;
} catch (e) {
return null;
}
}
function tagObjectLike(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const fields = readTagFields(value);
if (!fields.slug && !fields.name) return false;
return 'id' in value || 'type' in value || 'tag_type' in value || 'name_en' in value || 'name' in value || 'tag_name' in value;
}
function hasTagData(value) {
return !!(value && typeof value === 'object' &&
(value.id !== undefined && value.id !== null || value.tag_id !== undefined && value.tag_id !== null) &&
isTagRecord(value));
}
function extractTagFromItem(item) {
if (typeof item === 'string') return item ? { name: item, name_en: item } : null;
if (!item || typeof item !== 'object') return null;
if (tagObjectLike(item.tag)) return item.tag;
if (tagObjectLike(item)) return item;
for (const key of Object.keys(item)) {
const child = item[key];
if (child && typeof child === 'object' && !Array.isArray(child) && tagObjectLike(child)) return child;
}
return null;
}
function virtuosoState(scroller) {
let countState = null;
try {
let fiber = getFiber(scroller);
for (let level = 0; fiber && level < 16; level += 1) {
const props = fiber.memoizedProps;
if (
props &&
(props.itemContent || props.item) &&
(Array.isArray(props.data) || typeof props.totalCount === 'number')
) {
const state = {
data: Array.isArray(props.data) ? props.data : null,
totalCount: typeof props.totalCount === 'number' ? props.totalCount : null,
};
// Internal wrappers may expose only a count; the outer component has data.
if (state.data) {
if (state.totalCount === null && countState) state.totalCount = countState.totalCount;
return state;
}
if (!countState) countState = state;
}
fiber = fiber.return;
}
} catch (e) {}
return countState;
}
function countVirtuoso(state) {
if (!state) return 0;
if (state.data) return state.data.length;
return state.totalCount || 0;
}
function mergeVirtuosoTags(scroller, found, state = virtuosoState(scroller), entries) {
if (!state || !state.data) return countVirtuoso(state);
for (const item of state.data) {
addTagRecordToMap(found, item, entries);
}
return countVirtuoso(state);
}
function findPostTagsHeader() {
const labels = document.querySelectorAll('p.MuiTypography-root, p');
for (const label of labels) {
if (label.textContent.trim() !== 'Tags') continue;
const header = label.parentElement;
if (!header) continue;
if (!header.querySelector(':scope > .MuiInputBase-root')) continue;
const wrapper = header.parentElement;
if (!wrapper) continue;
if (wrapper.querySelector('a[href^="/tags/"]') || wrapper.querySelector('[data-virtuoso-scroller]')) return header;
}
return null;
}
function collectTags(scope, found) {
scope.querySelectorAll('a[href^="/tags/"]').forEach((anchor) => {
const href = anchor.getAttribute('href') || '';
const match = href.match(/^\/tags\/(.+)$/);
if (!match) return;
const slug = safeDecode(match[1].split('?')[0].split('#')[0].replace(/\/+$/, ''));
const labelEl = anchor.querySelector('.MuiChip-label') || anchor;
const name = labelEl.textContent.trim();
const key = normKey(slug) || normKey(name);
if (!key) return;
const existing = found.get(key);
if (existing && hasTagData(existing.raw)) return;
const raw = extractTagObject(anchor, slug, name);
mergeTagEntries([{ slug, name: name || slug, raw: hasTagData(raw) ? raw : null }], found);
});
}
function collectScrollers(scope) {
const scrollers = new Set();
scope.querySelectorAll('a[href^="/tags/"]').forEach((anchor) => {
const scroller = anchor.closest('[data-virtuoso-scroller]');
if (scroller) scrollers.add(scroller);
});
scope.querySelectorAll('[data-virtuoso-scroller]').forEach((scroller) => scrollers.add(scroller));
return Array.from(scrollers);
}
function mergeTagEntries(entries, found) {
for (const tag of entries) {
const key = normKey(tag.slug) || normKey(tag.name);
if (!key) continue;
const existing = found.get(key);
if (existing) {
if (hasTagData(tag.raw) && (!hasTagData(existing.raw) || scoreTagObject(tag.raw) > scoreTagObject(existing.raw))) existing.raw = tag.raw;
} else {
found.set(key, { slug: tag.slug, name: tag.name, raw: tag.raw });
}
}
}
let siteApi = null;
let siteRequire = null;
let siteApiCaptureQueued = false;
function getSiteApi() {
if (siteApi) return siteApi;
try {
if (!siteRequire && !siteApiCaptureQueued) {
const pageWindow = typeof unsafeWindow !== 'undefined' && unsafeWindow ? unsafeWindow : window;
const chunks = pageWindow.__LOADABLE_LOADED_CHUNKS__;
if (!chunks || typeof chunks.push !== 'function') return null;
siteApiCaptureQueued = true;
chunks.push([[`skk_tag_reader_${Date.now()}`], {}, (require) => { siteRequire = require; }]);
}
if (!siteRequire) return null;
// Retry module lookup through the same loader if the site's client arrives later.
const module = siteRequire('./src/shared/api.js');
const candidate = module.A || module.default;
if (candidate && typeof candidate.get === 'function') siteApi = candidate;
} catch (e) {}
return siteApi;
}
function readPostTagPage(payload) {
let items = null;
const candidates = [payload, payload && payload.tags, payload && payload.data,
payload && payload.data && payload.data.tags, payload && payload.results];
for (const candidate of candidates) {
if (Array.isArray(candidate) && candidate.every((item) => !!extractTagFromItem(item))) {
items = candidate;
break;
}
}
if (!items) throw new Error('Unrecognized post tag response');
let total = 0;
let hasMore = null;
let pageSize = 40;
for (const meta of [payload, payload && payload.meta, payload && payload.pagination,
payload && payload.pageInfo, payload && payload.data, payload && payload.data && payload.data.meta]) {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) continue;
for (const key of ['tag_count', 'tags_count', 'total_tag_count', 'total', 'totalCount']) {
const value = Number(meta[key]);
if (Number.isInteger(value) && value > total) total = value;
}
for (const key of ['has_more', 'hasMore', 'hasNextPage']) {
if (typeof meta[key] === 'boolean') hasMore = meta[key];
}
for (const key of ['limit', 'per_page', 'perPage']) {
if (Number.isInteger(meta[key]) && meta[key] > 0) pageSize = meta[key];
}
}
return { items, total, hasMore, pageSize };
}
async function waitForTagResponse(result, deadline) {
let settled = false;
let value;
let error;
Promise.resolve(result).then((response) => {
value = response;
settled = true;
}, (failure) => {
error = failure;
settled = true;
});
while (!settled && Date.now() < deadline) await sleep(100);
if (!settled) throw new Error('Tag request timed out');
if (error) throw error;
return value;
}
async function harvestPostTags(scope) {
const found = new Map();
const stats = { postId: getPostId(), api: 0, declared: 0, list: 0, total: 0,
sweeps: 0, requests: 0, loads: 0, found: 0, ms: 0, complete: false };
const started = Date.now();
const deadline = started + 15000;
let completed = false;
let apiFailed = false;
let lastApiScanVersion = -1;
let lastApiWantedSize = -1;
let loader = null;
const readCurrentTags = () => {
loader = null;
const entries = new WeakMap();
for (const scroller of collectScrollers(scope)) {
const state = virtuosoState(scroller);
mergeVirtuosoTags(scroller, found, state, entries);
if (state && state.data) stats.list = Math.max(stats.list, state.data.length);
if (state && state.totalCount) stats.total = Math.max(stats.total, state.totalCount);
// The containing tag section/post may hold more data than the rendered list.
let fiber = getFiber(scroller);
for (let level = 0; fiber && level < 32; level += 1, fiber = fiber.return) {
const props = fiber.memoizedProps;
if (!props) continue;
const sources = [props, props.currentPost, props.post];
for (const source of sources) {
if (!source || !Array.isArray(source.tags)) continue;
for (const item of source.tags) addTagRecordToMap(found, item, entries);
for (const key of ['tag_count', 'tags_count', 'total_tag_count', 'totalTags']) {
if (Number.isInteger(source[key])) stats.declared = Math.max(stats.declared, source[key]);
}
}
if (!loader && typeof props.endReached === 'function' &&
(props.itemContent || props.item) && (Array.isArray(props.data) || typeof props.totalCount === 'number')) {
loader = { props, count: Array.isArray(props.data) ? props.data.length : props.totalCount };
}
}
}
collectTags(scope, found);
if (stats.postId && (captureVersion !== lastApiScanVersion || found.size !== lastApiWantedSize)) {
lastApiScanVersion = captureVersion;
const wanted = new Set();
for (const entry of found.values()) {
wanted.add(normKey(entry.slug));
wanted.add(normKey(entry.name));
}
const api = findPostTagsFromApi(stats.postId, wanted);
if (api) {
mergeTagEntries(api.tags, found);
stats.api = Math.max(stats.api, api.tags.length);
stats.declared = Math.max(stats.declared, api.declared);
}
lastApiWantedSize = found.size;
}
return stats.declared > 0 && found.size >= Math.max(stats.declared, stats.list, stats.total) &&
Array.from(found.values()).every((entry) => hasTagData(entry.raw));
};
completed = readCurrentTags();
const client = completed ? null : getSiteApi();
if (!completed && client && stats.postId) {
// Fetch the full collection independently of the virtual list's visible rows.
// Count API records separately: existing UI data must not hide a repeated page.
const apiTags = new Map();
const endpoint = `posts/${encodeURIComponent(stats.postId)}/tags`;
try {
for (let page = 1; page <= 100 && Date.now() < deadline; page += 1) {
stats.requests += 1;
const response = await waitForTagResponse(client.get(endpoint, { params: { page, limit: 40 } }), deadline);
const payload = response && Object.prototype.hasOwnProperty.call(response, 'data') ? response.data : response;
const batch = readPostTagPage(payload);
const before = apiTags.size;
for (const item of batch.items) {
const entry = createTagEntry(item);
if (!entry) continue;
mergeTagEntries([entry], apiTags);
mergeTagEntries([entry], found);
}
stats.api = Math.max(stats.api, apiTags.size);
stats.declared = Math.max(stats.declared, batch.total);
const expected = Math.max(stats.declared, stats.list, stats.total);
if (stats.declared > 0 && apiTags.size >= expected) { completed = true; break; }
const exhausted = batch.hasMore === false || !batch.items.length ||
(batch.hasMore !== true && batch.items.length < batch.pageSize);
if (exhausted) {
completed = !expected || apiTags.size >= expected;
break;
}
if (apiTags.size === before) throw new Error('Tag API repeated a page without supplying missing tags');
}
} catch (error) {
stats.reason = String(error && error.message || 'Tag API request failed');
}
apiFailed = !completed;
}
// A build without the exposed API client can still run the exact lazy-load
// callback used by Virtuoso, without changing either scrollbar position.
if (!completed) {
let checkedSize = -1;
let checkedResponse = -1;
let checkedAt = started;
let stalledLoads = 0;
let previousSize = found.size;
const sizeBeforeFallback = found.size;
while (Date.now() < deadline) {
if (readCurrentTags()) { completed = true; break; }
if (found.size > previousSize) stalledLoads = 0;
previousSize = found.size;
const expected = Math.max(stats.declared, stats.list, stats.total);
const unchanged = found.size === checkedSize && tagResponseVersion === checkedResponse;
if (unchanged && pendingTagRequests === 0 && Date.now() - checkedAt >= 900) {
if ((!expected || found.size >= expected) &&
(!apiFailed || stats.declared > 0 || found.size > sizeBeforeFallback)) {
completed = true;
break;
}
if (stalledLoads >= 2) break;
}
if (loader && pendingTagRequests === 0 && (!unchanged || Date.now() - checkedAt >= 900)) {
checkedSize = found.size;
checkedResponse = tagResponseVersion;
checkedAt = Date.now();
stats.loads += 1;
stalledLoads += 1;
try {
await waitForTagResponse(loader.props.endReached(Math.max(0, loader.count - 1)), deadline);
} catch (error) {
stats.reason = String(error && error.message || 'Tag loader failed');
break;
}
} else if (!loader && pendingTagRequests === 0 && Date.now() - checkedAt >= 900) {
stats.reason = stats.reason || 'No accessible tag API or tag loader';
break;
}
await sleep(100);
}
}
readCurrentTags();
const expected = Math.max(stats.declared, stats.list, stats.total);
completed = completed && (!expected || found.size >= expected);
stats.found = found.size;
stats.complete = completed;
stats.ms = Date.now() - started;
return { tags: Array.from(found.values()), stats, complete: completed };
}
async function handleCopy(button) {
if (copyBusy || button.dataset.busy === '1') return;
copyBusy = true;
button.dataset.busy = '1';
const original = button.textContent;
button.textContent = 'Copying...';
let holdMs = 2500;
try {
const header = findPostTagsHeader();
const scope = header && header.parentElement ? header.parentElement : document;
const harvested = await harvestPostTags(scope);
const tags = harvested.tags;
const stats = harvested.stats;
console.info('[Sankaku Tags] copy stats', JSON.stringify(stats));
const knownTotal = Math.max(stats.declared, stats.total, stats.list);
button.title = JSON.stringify(stats);
if (!tags.length) {
button.textContent = 'No tags found';
} else if (!harvested.complete || (knownTotal && tags.length < knownTotal)) {
button.textContent = knownTotal && tags.length < knownTotal
? `Copy failed: only ${tags.length} of ${knownTotal} tags loaded`
: 'Copy failed: could not confirm all tags';
holdMs = 9000;
} else {
store.write({ source: location.href, copiedAt: Date.now(), complete: true, expectedCount: knownTotal || tags.length, tags });
const withData = tags.filter((tag) => hasTagData(tag.raw)).length;
const missing = tags.length - withData;
const suffix = missing ? ` (${missing} without data)` : '';
button.textContent = `Saved ${tags.length} tags${suffix}`;
}
} catch (error) {
console.error('[Sankaku Tags] copy failed', error);
button.textContent = 'Copy failed';
}
setTimeout(() => {
button.textContent = original;
delete button.dataset.busy;
copyBusy = false;
}, holdMs);
}
function getUploadRoot() {
const root = document.querySelector('.MuiAutocomplete-root[name="tags"]');
if (root) return root;
const input = document.querySelector('input[name="tags"]');
return input ? input.closest('.MuiAutocomplete-root') : null;
}
function getUploadHeader() {
const root = getUploadRoot();
if (!root) return null;
const box = root.closest('.MuiBox-root');
const header = box ? box.firstElementChild : null;
return header && header.tagName === 'DIV' ? header : null;
}
function getTagInput(root) {
return root.querySelector('input.MuiAutocomplete-input') || root.querySelector('input[type="text"]');
}
function chipCount(root) {
return root.querySelectorAll('[data-testid="tag-chip"]').length;
}
function setInputValue(input, value) {
const descriptor = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
if (descriptor && descriptor.set) descriptor.set.call(input, value);
else input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function chipKeys(root) {
return new Set(
Array.prototype.map.call(
root.querySelectorAll('[data-testid="tag-chip"]'),
(chip) => normKey(chip.textContent)
)
);
}
function isTagPresent(tag, keys) {
return [normKey(tag.slug), normKey(tag.name)].filter(Boolean).some((key) => keys.has(key));
}
function parseClipboardTags(text) {
if (!text) return [];
return text
.split(/[\s,;]+/)
.map((token) => token.trim())
.filter((token) => token && token.length < 200)
.map((token) => {
const decoded = safeDecode(token);
return { slug: decoded, name: decoded, raw: null };
});
}
function tagKeyOf(value) {
if (!value) return '';
if (typeof value === 'string') return normKey(value);
return normKey(value.name || value.name_en || '');
}
function autocompletePropsFromRoot(root) {
try {
let fiber = getFiber(root);
for (let level = 0; fiber && level < 14; level += 1) {
const props = fiber.memoizedProps;
if (
props &&
typeof props.onChange === 'function' &&
Array.isArray(props.value) &&
(props.renderInput || props.getOptionLabel || Array.isArray(props.options))
) {
return props;
}
fiber = fiber.return;
}
} catch (e) {}
return null;
}
function fakeChangeEvent() {
return {
type: 'change',
target: { value: '' },
currentTarget: { value: '' },
nativeEvent: { type: 'change' },
preventDefault() {},
stopPropagation() {},
persist() {},
};
}
function callAutocompleteChange(props, next, lastOption) {
const details = { option: lastOption };
try {
props.onChange(fakeChangeEvent(), next, 'selectOption', details);
return true;
} catch (e) {
try {
props.onChange(null, next, 'selectOption', details);
return true;
} catch (e2) {
console.warn('[Sankaku Tags] tag injection call failed', e2);
return false;
}
}
}
function injectTags(rawList) {
const root = getUploadRoot();
if (!root) return false;
const props = autocompletePropsFromRoot(root);
if (!props) return false;
const current = Array.isArray(props.value) ? props.value : [];
const existing = new Set(current.map((item) => tagKeyOf(item)).filter(Boolean));
const additions = [];
for (const raw of rawList) {
const key = tagKeyOf(raw);
if (!key || existing.has(key)) continue;
existing.add(key);
additions.push(raw);
}
if (!additions.length) return true;
const next = current.concat(additions);
return callAutocompleteChange(props, next, additions[additions.length - 1]);
}
function injectOneTag(raw) {
const root = getUploadRoot();
if (!root) return false;
const props = autocompletePropsFromRoot(root);
if (!props) return false;
const current = Array.isArray(props.value) ? props.value : [];
const key = tagKeyOf(raw);
if (!key) return false;
if (current.some((item) => tagKeyOf(item) === key)) return true;
const next = current.concat([raw]);
return callAutocompleteChange(props, next, raw);
}
async function waitForChips(tags, timeout) {
const start = Date.now();
while (Date.now() - start < timeout) {
const root = getUploadRoot();
if (!root) return;
const keys = chipKeys(root);
if (tags.every((tag) => isTagPresent(tag, keys))) return;
await sleep(100);
}
}
async function injectTagsIntoField(tags, button) {
const rawTags = tags.filter((tag) => hasTagData(tag.raw));
if (!rawTags.length) return;
const rootBefore = getUploadRoot();
if (!rootBefore) return;
const beforeKeys = chipKeys(rootBefore);
const todo = rawTags.filter((tag) => !isTagPresent(tag, beforeKeys));
if (!todo.length) return;
button.textContent = 'Injecting tag data...';
if (injectTags(todo.map((tag) => tag.raw))) {
await waitForChips(todo, 1600);
}
const rootAfter = getUploadRoot();
if (!rootAfter) return;
const afterKeys = chipKeys(rootAfter);
const missing = todo.filter((tag) => !isTagPresent(tag, afterKeys));
if (missing.length) console.warn('[Sankaku Tags] bulk injection left', missing.length, 'tag(s), retrying one by one');
for (const tag of missing) {
const liveRoot = getUploadRoot();
if (!liveRoot) break;
if (isTagPresent(tag, chipKeys(liveRoot))) continue;
if (!injectOneTag(tag.raw)) break;
await waitForChips([tag], 900);
}
}
async function rawTextPaste(text, timeout) {
const root = getUploadRoot();
if (!root) return 0;
const before = chipCount(root);
let dispatched = false;
try {
if (typeof DataTransfer === 'function' && typeof ClipboardEvent === 'function') {
const root = getUploadRoot();
const input = root && getTagInput(root);
if (input) {
const data = new DataTransfer();
data.setData('text/plain', text);
input.focus({ preventScroll: true });
input.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }));
dispatched = true;
}
}
} catch (e) {}
if (!dispatched) return 0;
const start = Date.now();
let last = before;
let lastChange = Date.now();
while (Date.now() - start < timeout) {
await sleep(120);
const liveRoot = getUploadRoot() || root;
const count = chipCount(liveRoot);
if (count !== last) {
last = count;
lastChange = Date.now();
} else if (count > before && Date.now() - lastChange > 600) {
return count - before;
}
}
const liveRoot = getUploadRoot() || root;
return Math.max(0, chipCount(liveRoot) - before);
}
function getUploadSelectedTags() {
const root = getUploadRoot();
if (!root) return [];
const tags = [];
const seen = new Set();
const props = autocompletePropsFromRoot(root);
if (props && Array.isArray(props.value)) {
for (const item of props.value) {
if (!item || typeof item !== 'object') continue;
const slug = typeof item.name === 'string' && item.name ? item.name : String(item.name_en || '');
const name = String(item.name_en || item.name || slug);
const key = normKey(slug) || normKey(name);
if (!key || seen.has(key)) continue;
seen.add(key);
const raw = sanitizeValue(item, 0, new Set());
tags.push({ slug, name, raw: raw && typeof raw === 'object' ? raw : null });
}
}
if (!tags.length) {
root.querySelectorAll('[data-testid="tag-chip"]').forEach((chip) => {
const text = String(chip.textContent || '').trim();
if (!text) return;
const key = normKey(text);
if (!key || seen.has(key)) return;
seen.add(key);
tags.push({ slug: text, name: text, raw: null });
});
}
return tags;
}
async function handleUploadCopy(button) {
if (button.dataset.busy === '1') return;
button.dataset.busy = '1';
const original = button.textContent;
try {
const tags = getUploadSelectedTags();
if (!tags.length) {
button.textContent = 'No tags in field';
} else {
store.write({ source: location.href, copiedAt: Date.now(), complete: true, expectedCount: tags.length, tags });
const withData = tags.filter((tag) => hasTagData(tag.raw)).length;
const missing = tags.length - withData;
const suffix = missing ? ` (${missing} without data)` : '';
button.textContent = `Saved ${tags.length} tags${suffix}`;
}
} catch (error) {
console.error('[Sankaku Tags] upload copy failed', error);
button.textContent = 'Copy failed';
}
setTimeout(() => {
button.textContent = original;
delete button.dataset.busy;
}, 2500);
}
async function handlePaste(button) {
if (button.dataset.busy === '1') return;
button.dataset.busy = '1';
const original = button.textContent;
try {
const root = getUploadRoot();
if (!root || !getTagInput(root)) {
button.textContent = 'Tag field not found';
setTimeout(() => { button.textContent = original; delete button.dataset.busy; }, 2500);
return;
}
let tags = [];
const bundle = store.read();
if (bundle && Array.isArray(bundle.tags)) tags = bundle.tags.filter((tag) => tag && (tag.slug || tag.name || tag.raw));
if (bundle && (bundle.complete === false || bundle.expectedCount > tags.length)) {
button.textContent = 'Incomplete tag bundle - copy again';
setTimeout(() => { button.textContent = original; delete button.dataset.busy; }, 3000);
return;
}
if (!tags.length) tags = parseClipboardTags(await readClipboardText());
if (!tags.length) {
button.textContent = 'No tags copied yet';
setTimeout(() => { button.textContent = original; delete button.dataset.busy; }, 2500);
return;
}
const initialCount = chipCount(root);
const initialKeys = chipKeys(root);
const skipped = tags.filter((tag) => isTagPresent(tag, initialKeys)).length;
await injectTagsIntoField(tags, button);
let liveRoot = getUploadRoot() || root;
const remainingKeys = chipKeys(liveRoot);
const remaining = tags.filter((tag) => !isTagPresent(tag, remainingKeys));
if (remaining.length) {
button.textContent = 'Pasting text...';
const text = remaining.map((tag) => tag.slug || tag.name).filter(Boolean).join(' ');
await rawTextPaste(text, 3000);
await sleep(200);
}
liveRoot = getUploadRoot() || root;
const finalInput = getTagInput(liveRoot);
if (finalInput) setInputValue(finalInput, '');
const added = Math.max(0, chipCount(liveRoot) - initialCount);
const finalKeys = chipKeys(liveRoot);
const stillMissing = tags.filter((tag) => !isTagPresent(tag, finalKeys));
if (!stillMissing.length) {
button.textContent = `Pasted ${added}${skipped ? `, ${skipped} already there` : ''}`;
} else {
// Only a failed automatic paste needs the system clipboard for Ctrl+V.
const text = stillMissing.map((tag) => tag.slug || tag.name).filter(Boolean).join(' ');
const clipboardReady = !!text && await copyToClipboard(text);
button.textContent = added > 0
? `Pasted ${added}, ${stillMissing.length} failed${clipboardReady ? ' - clipboard ready' : ''}`
: clipboardReady ? 'Clipboard ready - press Ctrl+V' : 'Paste failed';
}
} catch (error) {
console.error('[Sankaku Tags] paste failed', error);
button.textContent = 'Paste failed';
}
setTimeout(() => {
button.textContent = original;
delete button.dataset.busy;
}, 3000);
}
function makeButton(id, label, title) {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute(BTN_ATTR, id);
button.textContent = label;
button.title = title;
button.style.cssText = 'margin-left:8px;padding:4px 12px;border:1px solid #ff8c00;border-radius:4px;background:rgba(255,140,0,0.12);color:#ff8c00;font-size:12px;font-family:Roboto,Helvetica,Arial,sans-serif;font-weight:500;line-height:1.6;white-space:nowrap;cursor:pointer;flex-shrink:0;';
button.addEventListener('mouseenter', () => { button.style.background = 'rgba(255,140,0,0.25)'; });
button.addEventListener('mouseleave', () => { button.style.background = 'rgba(255,140,0,0.12)'; });
return button;
}
function insertButton(header, button) {
const label = header.querySelector(':scope > p');
if (label && label.nextElementSibling) header.insertBefore(button, label.nextElementSibling);
else header.appendChild(button);
}
function ensurePostButton(header = findPostTagsHeader()) {
if (!header) return;
if (header.querySelector(`[${BTN_ATTR}="copy"]`)) return;
if (document.querySelector(`[${BTN_ATTR}="copy"][data-busy="1"]`)) return;
const button = makeButton('copy', 'Copy Tags', 'Copy all tags of this post to the tag clipboard');
button.addEventListener('click', () => handleCopy(button));
insertButton(header, button);
}
function ensureUploadButton(header = getUploadHeader()) {
if (!header) return;
const label = header.querySelector(':scope > p');
let copyButton = header.querySelector(`[${BTN_ATTR}="upload-copy"]`);
if (!copyButton) {
copyButton = makeButton('upload-copy', 'Copy Tags', 'Copy the tags in this field to the tag clipboard');
copyButton.addEventListener('click', () => handleUploadCopy(copyButton));
if (label && label.nextElementSibling) header.insertBefore(copyButton, label.nextElementSibling);
else header.appendChild(copyButton);
}
if (!header.querySelector(`[${BTN_ATTR}="paste"]`)) {
const pasteButton = makeButton('paste', 'Paste Tags', 'Paste the copied tags into this field');
pasteButton.addEventListener('click', () => handlePaste(pasteButton));
header.insertBefore(pasteButton, copyButton.nextSibling);
}
}
function pruneButtons(postHeader = findPostTagsHeader(), uploadHeader = getUploadHeader()) {
document.querySelectorAll(`[${BTN_ATTR}="copy"]`).forEach((button) => {
if (button.dataset.busy === '1') return;
if (!postHeader || !postHeader.contains(button)) button.remove();
});
document.querySelectorAll(`[${BTN_ATTR}="paste"], [${BTN_ATTR}="upload-copy"]`).forEach((button) => {
if (!uploadHeader || !uploadHeader.contains(button)) button.remove();
});
}
const controls = { postHeader: null, uploadHeader: null, postButton: null, copyButton: null, pasteButton: null };
let discoverControls = true;
function controlsConnected() {
const { postHeader, uploadHeader, postButton, copyButton, pasteButton } = controls;
return (!postHeader || (postHeader.isConnected && postButton && postHeader.contains(postButton))) &&
(!uploadHeader || (uploadHeader.isConnected && copyButton && pasteButton &&
uploadHeader.contains(copyButton) && uploadHeader.contains(pasteButton)));
}
function refresh() {
try {
if (!discoverControls && controlsConnected()) return;
const postHeader = findPostTagsHeader();
const uploadHeader = getUploadHeader();
ensurePostButton(postHeader);
ensureUploadButton(uploadHeader);
pruneButtons(postHeader, uploadHeader);
controls.postHeader = postHeader;
controls.uploadHeader = uploadHeader;
controls.postButton = postHeader && postHeader.querySelector(`[${BTN_ATTR}="copy"]`);
controls.copyButton = uploadHeader && uploadHeader.querySelector(`[${BTN_ATTR}="upload-copy"]`);
controls.pasteButton = uploadHeader && uploadHeader.querySelector(`[${BTN_ATTR}="paste"]`);
discoverControls = false;
} catch (error) {
console.error('[Sankaku Tags]', error);
}
}
let refreshTimer = null;
const CONTROL_MARKERS = '[data-virtuoso-scroller], a[href^="/tags/"], .MuiAutocomplete-root[name="tags"], input[name="tags"]';
function affectsControls(record) {
if (record.target && record.target.tagName === 'P' && record.target.textContent.trim() === 'Tags') return true;
const inKnownSection = [controls.postHeader, controls.uploadHeader].some((header) =>
header && header.parentElement && header.parentElement.contains(record.target));
// An edit/upload field can appear inside a section that already has post controls.
const missingMarkers = [
!controls.uploadHeader && '.MuiAutocomplete-root[name="tags"], input[name="tags"]',
!controls.postHeader && '[data-virtuoso-scroller], a[href^="/tags/"]',
].filter(Boolean).join(', ');
if (inKnownSection && !missingMarkers) return false;
const markers = inKnownSection ? missingMarkers : CONTROL_MARKERS;
for (const node of record.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.matches(markers) || node.querySelector(markers)) return true;
if (inKnownSection) continue;
if ((node.tagName === 'P' && node.textContent.trim() === 'Tags') ||
Array.from(node.querySelectorAll('p')).some((label) => label.textContent.trim() === 'Tags')) return true;
}
return false;
}
function scheduleRefresh(records) {
if (Array.isArray(records)) {
const relevant = records.filter((record) => {
const target = record.target && (record.target.nodeType === 1 ? record.target : record.target.parentElement);
return !target || !target.closest(`[${BTN_ATTR}]`);
});
if (!relevant.length) return;
if (!controlsConnected() || relevant.some(affectsControls)) discoverControls = true;
} else {
discoverControls = true; // Initial readiness and the window load event.
}
if (refreshTimer) return;
refreshTimer = setTimeout(() => {
refreshTimer = null;
refresh();
}, 400);
}
const observer = new MutationObserver(scheduleRefresh);
if (document.documentElement) {
observer.observe(document.documentElement, { childList: true, subtree: true });
} else {
const readyTimer = setInterval(() => {
if (!document.documentElement) return;
clearInterval(readyTimer);
observer.observe(document.documentElement, { childList: true, subtree: true });
scheduleRefresh();
}, 50);
}
refresh();
window.addEventListener('load', scheduleRefresh);
})();