☰

Sankaku Tag Copy & Paste

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.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

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