ExGallery Url to Eagle

Extract files and tags from ExHentai/E-Hentai gallery pages and sync them to Eagle as bookmark items.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         ExGallery Url to Eagle
// @namespace    https://github.com/Mlietial/ExGallery-Url-to-Eagle
// @version      0.1.24
// @description       Extract files and tags from ExHentai/E-Hentai gallery pages and sync them to Eagle as bookmark items.
// @description:zh-CN 提取 ExHentai/E-Hentai 画廊页面的文件和标签,并将其作为书签项目同步到 Eagle。
// @description:ja    ExHentai/E-Hentai のギャラリーページからファイルとタグを抽出し、ブックマーク項目として Eagle に同期します。
// @author       Mliechoy
// @match        https://e-hentai.org/g/*/*
// @match        https://exhentai.org/g/*/*
// @icon         https://e-hentai.org/favicon.ico
// @icon64       https://e-hentai.org/favicon.ico
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      localhost
// @connect      127.0.0.1
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const CONFIG = {
    eagleBaseUrl: 'http://localhost:41595',
    folderIds: [],
    requestTimeoutMs: 15000,
    bookmarkMetadataSettleDelayMs: 3500,
    bookmarkMetadataRetryDelayMs: 2000,
    bookmarkMetadataRetryCount: 3,
    defaultTagSuffix: '',
    tagSuffixStorageKey: 'ex-eagle-sync-tag-suffix',
    panelPositionStorageKey: 'ex-eagle-sync-panel-position',
    panelUiStorageKey: 'ex-eagle-sync-panel-ui',
    tagEditsStorageKey: 'ex-eagle-sync-tag-edits',
    selectedFolderStorageKey: 'ex-eagle-sync-selected-folder-id',
    selectedTagGroupStorageKeys: {
      c: 'ex-eagle-sync-selected-c-tag-group-id',
      parody: 'ex-eagle-sync-selected-parody-tag-group-id',
      character: 'ex-eagle-sync-selected-character-tag-group-id',
      group: 'ex-eagle-sync-selected-group-tag-group-id',
      artist: 'ex-eagle-sync-selected-artist-tag-group-id',
      creator: 'ex-eagle-sync-selected-creator-tag-group-id'
    }
  };

  const UI = {
    panelId: 'ex-eagle-sync-panel',
    buttonId: 'ex-eagle-sync-button',
    refreshButtonId: 'ex-eagle-refresh-button',
    selectAllButtonId: 'ex-eagle-select-all-button',
    clearButtonId: 'ex-eagle-clear-button',
    folderSelectId: 'ex-eagle-folder-select',
    folderRefreshButtonId: 'ex-eagle-folder-refresh-button',
    tagSuffixInputId: 'ex-eagle-tag-suffix-input',
    tagSuffixApplyButtonId: 'ex-eagle-tag-suffix-apply-button',
    tagGroupSelectIds: {
      c: 'ex-eagle-c-tag-group-select',
      parody: 'ex-eagle-parody-tag-group-select',
      character: 'ex-eagle-character-tag-group-select',
      group: 'ex-eagle-group-tag-group-select',
      artist: 'ex-eagle-artist-tag-group-select'
    },
    tagGroupRefreshButtonIds: {
      c: 'ex-eagle-c-tag-group-refresh-button',
      parody: 'ex-eagle-parody-tag-group-refresh-button',
      character: 'ex-eagle-character-tag-group-refresh-button',
      group: 'ex-eagle-group-tag-group-refresh-button',
      artist: 'ex-eagle-artist-tag-group-refresh-button'
    },
    listId: 'ex-eagle-tag-list',
    summaryId: 'ex-eagle-tag-summary',
    statusId: 'ex-eagle-sync-status'
  };

  const state = {
    extractedTags: [],
    selectedTags: new Set(),
    tagSuffix: loadTagSuffix(),
    persistedTagEdits: loadPersistedTagEdits(),
    folderOptions: [],
    selectedFolderId: loadSelectedFolderId(),
    tagGroupOptions: [],
    selectedTagGroupIds: loadSelectedTagGroupIds()
  };

  const TAG_GROUP_ORDER = ['c', 'parody', 'character', 'group', 'artist'];

  const PANEL_LAYOUT = {
    margin: 8,
    defaultWidth: 340,
    minimizedWidth: 188,
    dockPeekSize: 22,
    edgeSnapThreshold: 28,
    rightEdgeGap: 2
  };

  const TAG_GROUP_DEFINITIONS = {
    parody: {
      label: 'Parody group',
      shortLabel: 'Parody',
      emptyLabel: 'No Parody Group',
      syncTargetLabel: 'parody tags'
    },
    character: {
      label: 'Character group',
      shortLabel: 'Character',
      emptyLabel: 'No Character Group',
      syncTargetLabel: 'character tags'
    },
    group: {
      label: 'Group group',
      shortLabel: 'Group',
      emptyLabel: 'No Group Group',
      syncTargetLabel: 'group tags'
    },
    artist: {
      label: 'Artist group',
      shortLabel: 'Artist',
      emptyLabel: 'No Artist Group',
      syncTargetLabel: 'artist tags'
    }
  };

  const NAMESPACE_ALIASES = {
    artist: 'artist',
    '\u827A\u672F\u5BB6': 'artist',
    group: 'group',
    '\u56E2\u961F': 'group',
    '\u5718\u968A': 'group',
    '\u793E\u56E2': 'group',
    '\u793E\u5718': 'group',
    parody: 'parody',
    '\u539F\u4F5C': 'parody',
    character: 'character',
    '\u89D2\u8272': 'character',
    female: 'female',
    '\u5973\u6027': 'female',
    male: 'male',
    '\u7537\u6027': 'male',
    mixed: 'mixed',
    '\u6DF7\u5408': 'mixed',
    other: 'other',
    '\u5176\u4ED6': 'other',
    reclass: 'reclass',
    '\u91CD\u65B0\u5206\u7C7B': 'reclass',
    temp: 'temp',
    language: 'language',
    '\u8BED\u8A00': 'language',
    misc: 'misc',
    '\u6742\u9879': 'misc'
  };

  const EXCLUDED_NAMESPACES = new Set([
    'language'
  ]);

  const SUFFIX_EXEMPT_NAMESPACES = new Set([
    'artist',
    'character',
    'parody',
    'group'
  ]);

  const EMOJI_REGEX = (() => {
    try {
      return new RegExp(
        '(?:[#*0-9]\\uFE0F?\\u20E3|\\p{Regional_Indicator}{2}|[\\p{Extended_Pictographic}\\p{Emoji_Presentation}])',
        'gu'
      );
    } catch {
      return /(?:[#*0-9]\uFE0F?\u20E3|[\u00A9\u00AE\u203C\u2049\u2122\u2139\u2194-\u21AA\u231A-\u231B\u2328\u23CF\u23E9-\u23FA\u24C2\u25AA-\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u27BF\u{1F000}-\u{1FAFF}])/gu;
    }
  })();

  function normalizeText(value) {
    return String(value || '').replace(/\s+/g, ' ').trim();
  }

  function stripEmoji(value) {
    return String(value || '')
      .replace(EMOJI_REGEX, '')
      .replace(/[\u200D\uFE0E\uFE0F\u20E3]/g, '');
  }

  function normalizeTagText(value) {
    return normalizeText(stripEmoji(value));
  }

  function sanitizeTagSuffix(value) {
    return String(value || '').replace(/\s+/g, '').trim();
  }

  function loadTagSuffix() {
    const stored = sanitizeTagSuffix(readPersistentValue(
      CONFIG.tagSuffixStorageKey,
      CONFIG.defaultTagSuffix
    ));
    return stored || CONFIG.defaultTagSuffix;
  }

  function saveTagSuffix(value) {
    const normalized = sanitizeTagSuffix(value) || CONFIG.defaultTagSuffix;
    writePersistentValue(CONFIG.tagSuffixStorageKey, normalized);
    return normalized;
  }

  function getCurrentTagSuffix() {
    return sanitizeTagSuffix(state.tagSuffix) || CONFIG.defaultTagSuffix;
  }

  function setCurrentTagSuffix(value) {
    const nextSuffix = saveTagSuffix(value);
    state.tagSuffix = nextSuffix;
    return nextSuffix;
  }

  function describeTagSuffix(value) {
    return sanitizeTagSuffix(value) || '(none)';
  }

  function escapeRegExp(value) {
    return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  }

  function stripManagedTagSuffix(value) {
    let current = normalizeTagText(value);
    const suffixes = Array.from(new Set([
      getCurrentTagSuffix(),
      CONFIG.defaultTagSuffix
    ].filter(Boolean))).sort((a, b) => b.length - a.length);

    suffixes.forEach((suffix) => {
      const pattern = new RegExp(`${escapeRegExp(suffix)}$`, 'i');
      if (pattern.test(current)) {
        current = normalizeTagText(current.replace(pattern, ''));
      }
    });

    return current;
  }

  function readPersistentValue(key, fallback = '') {
    try {
      if (typeof GM_getValue === 'function') {
        return GM_getValue(key, fallback);
      }
    } catch {
      // Fall through to localStorage.
    }

    try {
      const raw = localStorage.getItem(key);
      return raw == null ? fallback : raw;
    } catch {
      return fallback;
    }
  }

  function writePersistentValue(key, value) {
    try {
      if (typeof GM_setValue === 'function') {
        GM_setValue(key, value);
        return true;
      }
    } catch {
      // Fall through to localStorage.
    }

    try {
      localStorage.setItem(key, value);
      return true;
    } catch {
      return false;
    }
  }

  function loadPersistedTagEdits() {
    const raw = readPersistentValue(CONFIG.tagEditsStorageKey, '{}');
    if (raw && typeof raw === 'object') {
      return { ...raw };
    }

    try {
      const parsed = JSON.parse(String(raw || '{}'));
      return parsed && typeof parsed === 'object' ? parsed : {};
    } catch {
      return {};
    }
  }

  function savePersistedTagEdits() {
    writePersistentValue(
      CONFIG.tagEditsStorageKey,
      JSON.stringify(state.persistedTagEdits || {})
    );
  }

  function getPersistedTagValue(tagId) {
    const storedValue = normalizeText(state.persistedTagEdits?.[tagId] || '');
    const sanitizedValue = normalizeTagText(storedValue);
    if (storedValue !== sanitizedValue) {
      if (sanitizedValue) {
        state.persistedTagEdits[tagId] = sanitizedValue;
      } else if (state.persistedTagEdits && Object.prototype.hasOwnProperty.call(state.persistedTagEdits, tagId)) {
        delete state.persistedTagEdits[tagId];
      }
      savePersistedTagEdits();
    }
    return sanitizedValue;
  }

  function getManualTagOverrideValue(tag) {
    const tagId = normalizeText(tag?.id);
    if (!tagId) return '';

    const persistedValue = normalizeTagText(state.persistedTagEdits?.[tagId] || '');
    const defaultValue = normalizeTagText(tag?.defaultValue);
    if (!persistedValue || persistedValue === defaultValue) {
      return '';
    }
    return persistedValue;
  }

  function hasManualTagOverride(tag) {
    return Boolean(getManualTagOverrideValue(tag));
  }

  function setPersistedTagValue(tagId, value, defaultValue) {
    const normalizedValue = normalizeTagText(value);
    const normalizedDefault = normalizeTagText(defaultValue);

    if (!normalizedValue || normalizedValue === normalizedDefault) {
      if (state.persistedTagEdits && Object.prototype.hasOwnProperty.call(state.persistedTagEdits, tagId)) {
        delete state.persistedTagEdits[tagId];
        savePersistedTagEdits();
      }
      return;
    }

    if (!state.persistedTagEdits) {
      state.persistedTagEdits = {};
    }
    state.persistedTagEdits[tagId] = normalizedValue;
    savePersistedTagEdits();
  }

  function loadSelectedFolderId() {
    return normalizeText(readPersistentValue(CONFIG.selectedFolderStorageKey, ''));
  }

  function saveSelectedFolderId(folderId) {
    writePersistentValue(CONFIG.selectedFolderStorageKey, normalizeText(folderId));
  }

  function loadSelectedTagGroupId(groupKey) {
    const storageKey = CONFIG.selectedTagGroupStorageKeys[groupKey];
    if (!storageKey) return '';
    return normalizeText(readPersistentValue(storageKey, ''));
  }

  function loadSelectedTagGroupIds() {
    const loaded = Object.keys(CONFIG.selectedTagGroupStorageKeys || {}).reduce((acc, groupKey) => {
      acc[groupKey] = loadSelectedTagGroupId(groupKey);
      return acc;
    }, {});

    const legacyCreatorGroupId = normalizeText(loaded.creator);
    if (legacyCreatorGroupId) {
      if (!normalizeText(loaded.group)) {
        loaded.group = legacyCreatorGroupId;
      }
      if (!normalizeText(loaded.artist)) {
        loaded.artist = legacyCreatorGroupId;
      }
    }

    return loaded;
  }

  function saveSelectedTagGroupId(groupKey, groupId) {
    const storageKey = CONFIG.selectedTagGroupStorageKeys[groupKey];
    if (!storageKey) return;
    writePersistentValue(storageKey, normalizeText(groupId));
  }

  function loadPanelPosition() {
    try {
      const raw = localStorage.getItem(CONFIG.panelPositionStorageKey);
      if (!raw) return null;
      const parsed = JSON.parse(raw);
      if (!parsed || typeof parsed.left !== 'number' || typeof parsed.top !== 'number') {
        return null;
      }
      return parsed;
    } catch {
      return null;
    }
  }

  function savePanelPosition(position) {
    try {
      localStorage.setItem(CONFIG.panelPositionStorageKey, JSON.stringify(position));
    } catch {
      // Ignore storage failures.
    }
  }

  function loadPanelUiState() {
    try {
      const raw = localStorage.getItem(CONFIG.panelUiStorageKey);
      if (!raw) {
        return {
          minimized: false,
          docked: false,
          dockSide: 'right',
          controlsCollapsed: false
        };
      }
      const parsed = JSON.parse(raw);
      return {
        minimized: Boolean(parsed?.minimized),
        docked: Boolean(parsed?.docked),
        dockSide: parsed?.dockSide === 'left' ? 'left' : 'right',
        controlsCollapsed: Boolean(parsed?.controlsCollapsed)
      };
    } catch {
      return {
        minimized: false,
        docked: false,
        dockSide: 'right',
        controlsCollapsed: false
      };
    }
  }

  function savePanelUiState(uiState) {
    try {
      localStorage.setItem(CONFIG.panelUiStorageKey, JSON.stringify({
        minimized: Boolean(uiState?.minimized),
        docked: Boolean(uiState?.docked),
        dockSide: uiState?.dockSide === 'left' ? 'left' : 'right',
        controlsCollapsed: Boolean(uiState?.controlsCollapsed)
      }));
    } catch {
      // Ignore storage failures.
    }
  }

  function clamp(value, min, max) {
    return Math.min(Math.max(value, min), max);
  }

  function getViewportRightInset() {
    const clientWidth = document.documentElement?.clientWidth;
    if (typeof clientWidth !== 'number' || !Number.isFinite(clientWidth)) {
      return 0;
    }
    return Math.max(window.innerWidth - clientWidth, 0);
  }

  function getViewportContentWidth() {
    return Math.max(window.innerWidth - getViewportRightInset(), 0);
  }

  function getPanelAvailableHeight() {
    return Math.max(window.innerHeight - PANEL_LAYOUT.margin * 2, 240);
  }

  function getStoredPanelExpandedHeight(panel) {
    const raw = Number.parseFloat(panel?.dataset?.expandedHeight || '');
    return Number.isFinite(raw) && raw > 0 ? raw : 0;
  }

  function capturePanelExpandedHeight(panel) {
    if (!panel) return 0;
    const measuredHeight = Math.round(panel.getBoundingClientRect().height);
    if (measuredHeight > 0) {
      panel.dataset.expandedHeight = String(measuredHeight);
    }
    return measuredHeight;
  }

  function readPanelUiState(panel) {
    return {
      minimized: panel?.dataset?.minimized === 'true',
      docked: panel?.dataset?.docked === 'true',
      dockSide: panel?.dataset?.dockSide === 'left' ? 'left' : 'right',
      controlsCollapsed: panel?.dataset?.controlsCollapsed === 'true'
    };
  }

  function writePanelUiState(panel, nextState) {
    const normalizedState = {
      minimized: Boolean(nextState?.minimized),
      docked: Boolean(nextState?.docked),
      dockSide: nextState?.dockSide === 'left' ? 'left' : 'right',
      controlsCollapsed: Boolean(nextState?.controlsCollapsed)
    };
    panel.dataset.minimized = normalizedState.minimized ? 'true' : 'false';
    panel.dataset.docked = normalizedState.docked ? 'true' : 'false';
    panel.dataset.dockSide = normalizedState.dockSide;
    panel.dataset.controlsCollapsed = normalizedState.controlsCollapsed ? 'true' : 'false';
    savePanelUiState(normalizedState);
    return normalizedState;
  }

  function isPanelDockOpen(panel) {
    return panel?.dataset?.dockOpen === 'true';
  }

  function setPanelDockOpen(panel, isOpen) {
    panel.dataset.dockOpen = isOpen ? 'true' : 'false';
    updatePanelChrome(panel);
  }

  function blurPanelActiveElement(panel) {
    const activeElement = document.activeElement;
    if (activeElement && panel?.contains(activeElement) && typeof activeElement.blur === 'function') {
      activeElement.blur();
    }
  }

  function blurPanelButtonFocus(panel) {
    const activeElement = document.activeElement;
    if (
      activeElement
      && panel?.contains(activeElement)
      && String(activeElement.tagName || '').toUpperCase() === 'BUTTON'
      && typeof activeElement.blur === 'function'
    ) {
      activeElement.blur();
      return true;
    }
    return false;
  }

  function getNearestDockSide(panel) {
    const rect = panel.getBoundingClientRect();
    return rect.left + rect.width / 2 < getViewportContentWidth() / 2 ? 'left' : 'right';
  }

  function getPanelLeftBounds(panel) {
    const uiState = readPanelUiState(panel);
    const minLeft = uiState.docked ? 0 : PANEL_LAYOUT.margin;
    const maxLeft = Math.max(
      getViewportContentWidth() - panel.offsetWidth - (uiState.docked ? PANEL_LAYOUT.rightEdgeGap : PANEL_LAYOUT.margin),
      minLeft
    );
    return { minLeft, maxLeft };
  }

  function applyPanelPosition(panel, position) {
    const { minLeft, maxLeft } = getPanelLeftBounds(panel);
    const maxTop = Math.max(window.innerHeight - panel.offsetHeight - PANEL_LAYOUT.margin, PANEL_LAYOUT.margin);
    const left = clamp(position.left, minLeft, maxLeft);
    const top = clamp(position.top, PANEL_LAYOUT.margin, maxTop);
    panel.style.left = `${left}px`;
    panel.style.top = `${top}px`;
    panel.style.right = 'auto';
    savePanelPosition({ left, top });
    return { left, top };
  }

  function snapPanelToDockSide(panel, dockSide) {
    const side = dockSide === 'left' ? 'left' : 'right';
    const currentTop = Number.parseFloat(panel.style.top) || panel.getBoundingClientRect().top || PANEL_LAYOUT.margin;
    const maxTop = Math.max(window.innerHeight - panel.offsetHeight - PANEL_LAYOUT.margin, PANEL_LAYOUT.margin);
    const top = clamp(currentTop, PANEL_LAYOUT.margin, maxTop);
    const contentWidth = getViewportContentWidth();
    const left = side === 'left'
      ? 0
      : Math.max(contentWidth - panel.offsetWidth - PANEL_LAYOUT.rightEdgeGap, 0);

    panel.style.left = `${left}px`;
    panel.style.top = `${top}px`;
    panel.style.right = 'auto';
    savePanelPosition({ left, top });
    return { left, top };
  }

  function updatePanelChrome(panel) {
    if (!panel) return;

    const uiState = readPanelUiState(panel);
    const body = panel.querySelector('[data-ex-eagle-panel-body="1"]');
    const controlsSection = panel.querySelector('[data-ex-eagle-controls-section="1"]');
    const controlsToggleButton = panel.querySelector('[data-ex-eagle-controls-toggle="1"]');
    const list = panel.querySelector(`#${UI.listId}`);
    const minimizeButton = panel.querySelector('[data-ex-eagle-panel-minimize="1"]');
    const dockButton = panel.querySelector('[data-ex-eagle-panel-dock="1"]');
    const dockPeek = panel.querySelector('[data-ex-eagle-panel-peek="1"]');
    const targetDockSide = uiState.docked ? uiState.dockSide : getNearestDockSide(panel);
    const dockOpen = uiState.docked && isPanelDockOpen(panel);
    const expandedHeight = getStoredPanelExpandedHeight(panel);
    const targetHeight = uiState.controlsCollapsed && expandedHeight
      ? clamp(expandedHeight, 240, getPanelAvailableHeight())
      : 0;

    panel.style.width = `${uiState.minimized ? PANEL_LAYOUT.minimizedWidth : PANEL_LAYOUT.defaultWidth}px`;
    panel.style.height = uiState.minimized
      ? 'auto'
      : uiState.controlsCollapsed && targetHeight
        ? `${targetHeight}px`
        : 'auto';
    panel.style.padding = uiState.minimized ? '8px 10px' : '10px';
    panel.style.gap = uiState.minimized ? '0' : '8px';
    panel.style.borderRadius = uiState.minimized ? '999px' : '6px';
    panel.style.transform = uiState.docked && !dockOpen
      ? (uiState.dockSide === 'left'
          ? `translateX(calc(-100% + ${PANEL_LAYOUT.dockPeekSize}px))`
          : `translateX(calc(100% - ${PANEL_LAYOUT.dockPeekSize}px))`)
      : 'translateX(0)';
    panel.style.opacity = uiState.docked && !dockOpen ? '0.94' : '1';

    if (body) {
      body.style.display = uiState.minimized ? 'none' : 'flex';
    }

    if (controlsSection) {
      controlsSection.style.display = uiState.minimized || !uiState.controlsCollapsed ? 'flex' : 'none';
    }

    if (list) {
      list.style.flex = uiState.minimized
        ? '0 0 auto'
        : uiState.controlsCollapsed
          ? '1 1 auto'
          : '0 0 auto';
      list.style.minHeight = uiState.minimized ? '0' : '0';
      list.style.maxHeight = uiState.minimized
        ? '260px'
        : uiState.controlsCollapsed
          ? 'none'
          : '260px';
    }

    if (controlsToggleButton) {
      controlsToggleButton.textContent = uiState.controlsCollapsed ? '+' : '^';
      controlsToggleButton.title = uiState.controlsCollapsed ? 'Expand controls' : 'Collapse controls';
    }

    if (minimizeButton) {
      minimizeButton.textContent = uiState.minimized ? '+' : '-';
      minimizeButton.title = uiState.minimized ? 'Expand panel' : 'Minimize panel';
    }

    if (dockButton) {
      dockButton.textContent = targetDockSide === 'left' ? '<' : '>';
      dockButton.title = uiState.docked
        ? 'Disable edge hide'
        : `Hide to ${targetDockSide === 'left' ? 'left' : 'right'} edge`;
    }

    if (dockPeek) {
      dockPeek.style.display = uiState.docked && !dockOpen ? 'flex' : 'none';
      dockPeek.style.left = uiState.dockSide === 'right' ? '0' : 'auto';
      dockPeek.style.right = uiState.dockSide === 'left' ? '0' : 'auto';
      dockPeek.style.borderLeft = uiState.dockSide === 'right' ? '1px solid #5c0d12' : 'none';
      dockPeek.style.borderRight = uiState.dockSide === 'left' ? '1px solid #5c0d12' : 'none';
      dockPeek.style.borderRadius = uiState.dockSide === 'left' ? '0 6px 6px 0' : '6px 0 0 6px';
    }
  }

  function setPanelMinimized(panel, minimized) {
    const rect = panel.getBoundingClientRect();
    const currentState = readPanelUiState(panel);
    const nextState = writePanelUiState(panel, {
      ...currentState,
      minimized: Boolean(minimized)
    });

    updatePanelChrome(panel);

    if (nextState.docked) {
      snapPanelToDockSide(panel, nextState.dockSide);
    } else {
      applyPanelPosition(panel, {
        left: rect.left,
        top: rect.top
      });
    }
  }

  function setPanelDocked(panel, docked, preferredDockSide, options = {}) {
    const rect = panel.getBoundingClientRect();
    const currentState = readPanelUiState(panel);
    const nextState = writePanelUiState(panel, {
      ...currentState,
      docked: Boolean(docked),
      dockSide: preferredDockSide === 'left' || preferredDockSide === 'right'
        ? preferredDockSide
        : currentState.dockSide
    });

    if (nextState.docked) {
      if (!options.preserveOpen) {
        blurPanelActiveElement(panel);
      }
      panel.dataset.dockOpen = options.preserveOpen ? 'true' : 'false';
      snapPanelToDockSide(panel, nextState.dockSide);
    } else {
      panel.dataset.dockOpen = 'false';
      applyPanelPosition(panel, {
        left: rect.left,
        top: rect.top
      });
    }

    updatePanelChrome(panel);
    return nextState;
  }

  function setPanelControlsCollapsed(panel, controlsCollapsed) {
    const nextCollapsed = Boolean(controlsCollapsed);
    if (nextCollapsed) {
      capturePanelExpandedHeight(panel);
    }
    const currentState = readPanelUiState(panel);
    writePanelUiState(panel, {
      ...currentState,
      controlsCollapsed: nextCollapsed
    });
    updatePanelChrome(panel);
  }

  function maybeDockPanelToEdge(panel, options = {}) {
    const rect = panel.getBoundingClientRect();
    const nearLeft = rect.left <= PANEL_LAYOUT.edgeSnapThreshold;
    const nearRight = getViewportContentWidth() - rect.right <= PANEL_LAYOUT.edgeSnapThreshold;

    if (nearLeft || nearRight) {
      setPanelDocked(panel, true, nearLeft ? 'left' : 'right', {
        preserveOpen: Boolean(options?.preserveOpen)
      });
      return true;
    }

    const uiState = readPanelUiState(panel);
    if (uiState.docked) {
      writePanelUiState(panel, {
        ...uiState,
        docked: false
      });
      panel.dataset.dockOpen = 'false';
      applyPanelPosition(panel, {
        left: rect.left,
        top: rect.top
      });
      updatePanelChrome(panel);
      return true;
    }

    return false;
  }

  function handlePanelViewportChange(panel) {
    const uiState = readPanelUiState(panel);
    if (uiState.docked) {
      snapPanelToDockSide(panel, uiState.dockSide);
    } else {
      const savedPosition = loadPanelPosition();
      const rect = panel.getBoundingClientRect();
      applyPanelPosition(panel, savedPosition || {
        left: rect.left || Math.max(getViewportContentWidth() - panel.offsetWidth - 16, PANEL_LAYOUT.margin),
        top: rect.top || 16
      });
    }
    updatePanelChrome(panel);
  }

  function enablePanelDockHover(panel) {
    let hideTimer = 0;
    let pointerInteracting = false;

    const clearHideTimer = () => {
      if (!hideTimer) return;
      window.clearTimeout(hideTimer);
      hideTimer = 0;
    };

    const showDockedPanel = () => {
      clearHideTimer();
      if (readPanelUiState(panel).docked) {
        setPanelDockOpen(panel, true);
      }
    };

    const hideDockedPanel = () => {
      clearHideTimer();
      if (readPanelUiState(panel).docked) {
        setPanelDockOpen(panel, false);
      }
    };

    const scheduleHideDockedPanel = () => {
      clearHideTimer();
      hideTimer = window.setTimeout(() => {
        hideTimer = 0;
        if (!readPanelUiState(panel).docked) return;
        if (pointerInteracting) return;
        if (panel.matches(':hover')) return;
        if (panel.contains(document.activeElement)) return;
        setPanelDockOpen(panel, false);
      }, 120);
    };

    panel.addEventListener('mouseenter', showDockedPanel);
    panel.addEventListener('mouseleave', () => {
      blurPanelButtonFocus(panel);
      scheduleHideDockedPanel();
    });
    panel.addEventListener('focusin', showDockedPanel);
    panel.addEventListener('click', (event) => {
      const button = typeof event.target?.closest === 'function'
        ? event.target.closest('button')
        : null;
      if (!button || !panel.contains(button)) return;
      window.setTimeout(() => {
        if (!readPanelUiState(panel).docked) return;
        if (typeof button.blur === 'function') {
          button.blur();
        }
        if (!panel.matches(':hover') && !panel.contains(document.activeElement)) {
          scheduleHideDockedPanel();
        }
      }, 0);
    });
    panel.addEventListener('pointerdown', () => {
      pointerInteracting = true;
      showDockedPanel();
    });
    window.addEventListener('pointerup', () => {
      if (!pointerInteracting) return;
      pointerInteracting = false;
      if (readPanelUiState(panel).docked && !panel.matches(':hover') && !panel.contains(document.activeElement)) {
        scheduleHideDockedPanel();
      }
    });
    panel.addEventListener('focusout', () => {
      window.setTimeout(() => {
        if (readPanelUiState(panel).docked && !panel.matches(':hover') && !panel.contains(document.activeElement)) {
          scheduleHideDockedPanel();
        }
      }, 0);
    });
  }

  function enablePanelDrag(panel, handle) {
    let dragState = null;

    handle.addEventListener('mousedown', (event) => {
      if (event.button !== 0) return;
      if (typeof event.target?.closest === 'function' && event.target.closest('button, select, input, textarea')) {
        return;
      }
      if (readPanelUiState(panel).docked) {
        setPanelDockOpen(panel, true);
      }
      const rect = panel.getBoundingClientRect();
      const uiState = readPanelUiState(panel);
      dragState = {
        offsetX: event.clientX - rect.left,
        offsetY: event.clientY - rect.top,
        wasDocked: uiState.docked,
        wasDockOpen: isPanelDockOpen(panel)
      };
      document.body.style.userSelect = 'none';
      event.preventDefault();
    });

    window.addEventListener('mousemove', (event) => {
      if (!dragState) return;
      applyPanelPosition(panel, {
        left: event.clientX - dragState.offsetX,
        top: event.clientY - dragState.offsetY
      });
    });

    window.addEventListener('mouseup', () => {
      if (!dragState) return;
      const shouldPreserveOpen = dragState.wasDocked && dragState.wasDockOpen;
      dragState = null;
      document.body.style.userSelect = '';
      maybeDockPanelToEdge(panel, {
        preserveOpen: shouldPreserveOpen
      });
    });
  }

  function normalizeNamespace(value) {
    return normalizeText(value).replace(/[:\uFF1A]\s*$/, '').toLowerCase();
  }

  function canonicalizeNamespace(value) {
    const normalized = normalizeNamespace(value);
    return NAMESPACE_ALIASES[normalized] || normalized;
  }

  function decodeStableTagToken(token) {
    return normalizeText(String(token || '')
      .replace(/^\s+|\s+$/g, '')
      .replace(/\+/g, ' ')
      .replace(/_/g, ' '));
  }

  function buildTagId(namespace, rawText, anchor) {
    const href = String(anchor?.getAttribute?.('href') || '');
    const hrefMatch = href.match(/\/tag\/([^/?#]+)/i);
    if (hrefMatch?.[1]) {
      try {
        const decoded = decodeStableTagToken(decodeURIComponent(hrefMatch[1]));
        if (decoded) return `tag::${decoded.toLowerCase()}`;
      } catch {
        const decoded = decodeStableTagToken(hrefMatch[1]);
        if (decoded) return `tag::${decoded.toLowerCase()}`;
      }
    }

    const onclick = String(anchor?.getAttribute?.('onclick') || '');
    const onclickMatch = onclick.match(/'([^']+)'/);
    if (onclickMatch?.[1]) {
      const decoded = decodeStableTagToken(onclickMatch[1]);
      if (decoded) return `tag::${decoded.toLowerCase()}`;
    }

    return `${namespace}::${normalizeText(rawText).toLowerCase()}`;
  }

  function formatTag(namespace, tagText) {
    const cleaned = stripManagedTagSuffix(tagText);
    if (!cleaned) return '';
    return namespace === 'artist' || namespace === 'character' || namespace === 'parody' || namespace === 'group'
      ? cleaned
      : `${cleaned}${getCurrentTagSuffix()}`;
  }

  function normalizeUrl(rawUrl) {
    try {
      const url = new URL(rawUrl, location.href);
      url.hash = '';
      return url.origin + url.pathname;
    } catch {
      return String(rawUrl || '').split('#')[0];
    }
  }

  function cleanTitleCandidate(value) {
    return normalizeText(value)
      .replace(/\s+-\s+ExHentai\.org$/i, '')
      .replace(/\s+-\s+E-Hentai(?:\s+Galleries)?$/i, '');
  }

  function containsCjk(value) {
    return /[\u3040-\u30FF\u3400-\u9FFF]/u.test(String(value || ''));
  }

  function containsLatin(value) {
    return /[A-Za-z]/.test(String(value || ''));
  }

  function splitTitleVariants(value) {
    const cleaned = cleanTitleCandidate(value);
    if (!cleaned) return [];
    const parts = cleaned
      .split(/\s+[||]\s+/)
      .map(normalizeText)
      .filter(Boolean);
    return parts.length ? parts : [cleaned];
  }

  function stripLeadingTitleMetadata(value) {
    let current = normalizeText(value);
    let previous = '';

    while (current && current !== previous) {
      previous = current;
      current = current
        .replace(/^\([^()]{1,80}\)\s*/u, '')
        .replace(/^\[[^[\]]{1,160}\]\s*/u, '')
        .trim();
    }

    return current;
  }

  function stripTrailingBracketMetadata(value) {
    let current = normalizeText(value);
    let previous = '';

    while (current && current !== previous) {
      previous = current;
      current = current.replace(/\s*\[[^[\]]+\]\s*$/u, '').trim();
    }

    return current;
  }

  function stripTrailingSourceMarker(value) {
    const current = normalizeText(value);
    const match = current.match(/^(.*?)(?:\s*[\uFF08(]([^()\uFF08\uFF09]+)[)\uFF09])$/u);
    if (!match) return current;

    const before = normalizeText(match[1]);
    const inner = normalizeText(match[2]);
    if (!before || !inner) return current;

    if (containsCjk(before) && containsLatin(inner) && !containsCjk(inner)) {
      return before;
    }

    return current;
  }

  function stripSpacedTrailingParentheticalMetadata(value) {
    let current = normalizeText(value);
    let previous = '';

    while (current && current !== previous) {
      previous = current;
      current = current
        .replace(/\s+[\uFF08(][^()\uFF08\uFF09]+[)\uFF09]\s*$/u, '')
        .trim();
    }

    return current;
  }

  function preferCjkTitleSegment(value) {
    const current = normalizeText(value);
    if (!containsCjk(current) || !containsLatin(current)) {
      return current;
    }

    const firstCjkIndex = current.search(/[\u3040-\u30FF\u3400-\u9FFF]/u);
    if (firstCjkIndex <= 0) {
      return current;
    }

    const preferred = current
      .slice(firstCjkIndex)
      .replace(/^[\s||::/\-–—]+/u, '')
      .trim();

    return preferred || current;
  }

  function normalizeTitleBlockSpacing(value) {
    return normalizeText(value)
      .replace(/\)\s+\[/g, ')[')
      .replace(/)\s+\[/g, ')[');
  }

  function simplifyLeadingCreatorPrefix(value) {
    const current = normalizeText(value);
    if (!current) return '';

    const withEventMatch = current.match(
      /^\s*[\uFF08(][^()\uFF08\uFF09]{1,80}[)\uFF09]\s*\[([^[\]]+?)\s*[\uFF08(]([^()\uFF08\uFF09]+)[)\uFF09]\]\s*(.+)$/u
    );
    if (withEventMatch) {
      const artist = normalizeText(withEventMatch[2]);
      const title = normalizeText(withEventMatch[3]);
      return artist && title ? `[${artist}] ${title}` : current;
    }

    const bracketOnlyMatch = current.match(
      /^\s*\[([^[\]]+?)\s*[\uFF08(]([^()\uFF08\uFF09]+)[)\uFF09]\]\s*(.+)$/u
    );
    if (bracketOnlyMatch) {
      const artist = normalizeText(bracketOnlyMatch[2]);
      const title = normalizeText(bracketOnlyMatch[3]);
      return artist && title ? `[${artist}] ${title}` : current;
    }

    return current;
  }

  function normalizeGalleryTitleCandidate(value, options = {}) {
    const cleaned = cleanTitleCandidate(value);
    if (!cleaned) return '';

    let current = cleaned;
    if (options.simplifyCreatorPrefix !== false) {
      current = simplifyLeadingCreatorPrefix(current);
    }
    if (!options.keepLeadingMetadata) {
      current = stripLeadingTitleMetadata(current);
    }
    if (options.preferCjkSegment) {
      current = preferCjkTitleSegment(current);
    }
    current = stripTrailingBracketMetadata(current);
    current = stripSpacedTrailingParentheticalMetadata(current);
    current = stripTrailingSourceMarker(current);
    current = stripTrailingBracketMetadata(current);
    return normalizeTitleBlockSpacing(current);
  }

  function getPreferredGalleryTitle() {
    const originalTitle = normalizeGalleryTitleCandidate(
      document.querySelector('#gj')?.textContent || '',
      { keepLeadingMetadata: true }
    );
    if (originalTitle) {
      return originalTitle;
    }

    const displayTitleVariants = splitTitleVariants(document.querySelector('#gn')?.textContent || '');
    for (let i = 0; i < displayTitleVariants.length; i++) {
      const displayTitle = normalizeGalleryTitleCandidate(displayTitleVariants[i], {
        keepLeadingMetadata: true,
        preferCjkSegment: false
      });
      if (displayTitle) {
        return displayTitle;
      }
    }

    const documentTitleVariants = splitTitleVariants(document.title || '');
    for (let i = 0; i < documentTitleVariants.length; i++) {
      const documentTitle = normalizeGalleryTitleCandidate(documentTitleVariants[i], {
        keepLeadingMetadata: true,
        preferCjkSegment: i > 0
      });
      if (documentTitle) {
        return documentTitle;
      }
    }

    return '';
  }

  function collectGalleryTags() {
    const tagList = document.querySelector('#taglist');
    if (!tagList) {
      return [];
    }

    const tags = [];
    const seen = new Set();
    const rows = tagList.querySelectorAll('tr');

    rows.forEach((row) => {
      const namespace = canonicalizeNamespace(row.querySelector('td.tc')?.textContent || 'misc');
      if (EXCLUDED_NAMESPACES.has(namespace)) return;
      const anchors = row.querySelectorAll('a[id^="ta_"]');

      anchors.forEach((anchor) => {
        const rawText = normalizeText(anchor.textContent);
        const value = formatTag(namespace, rawText);
        const id = buildTagId(namespace, rawText, anchor);
        if (!value || seen.has(id)) return;
        seen.add(id);
        const persistedValue = getPersistedTagValue(id);
        tags.push({
          id,
          namespace,
          rawText,
          defaultValue: value,
          value: persistedValue || value
        });
      });
    });
    return tags;
  }

  function collectGalleryMeta(tagsOverride) {
    const pageUrl = normalizeUrl(location.href);
    const title = getPreferredGalleryTitle();
    const tags = Array.isArray(tagsOverride) ? tagsOverride : collectGalleryTags();

    return {
      pageUrl,
      title,
      tags
    };
  }

  function gmRequest(options) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: options.method || 'GET',
        url: options.url,
        data: options.data ? JSON.stringify(options.data) : undefined,
        headers: options.data ? { 'Content-Type': 'application/json' } : undefined,
        timeout: CONFIG.requestTimeoutMs,
        onload(response) {
          const raw = response.responseText || '';
          let body = null;

          try {
            body = raw ? JSON.parse(raw) : null;
          } catch {
            body = raw;
          }

          if (response.status >= 200 && response.status < 300) {
            resolve(body);
            return;
          }

          const message = typeof body === 'object' && body
            ? (body.message || JSON.stringify(body))
            : `HTTP ${response.status}`;
          reject(new Error(message));
        },
        onerror(error) {
          reject(new Error(error?.error || 'Request failed'));
        },
        ontimeout() {
          reject(new Error('Request to Eagle timed out'));
        }
      });
    });
  }

  function unwrapJSend(payload, fallbackMessage) {
    if (!payload || typeof payload !== 'object') {
      throw new Error(fallbackMessage);
    }

    if (payload.status === 'success') {
      return payload.data;
    }

    if (payload.status === 'error') {
      throw new Error(payload.message || fallbackMessage);
    }

    return payload.data !== undefined ? payload.data : payload;
  }

  async function ensureEagleAvailable() {
    const response = await gmRequest({
      method: 'GET',
      url: `${CONFIG.eagleBaseUrl}/api/v2/app/info`
    });
    return unwrapJSend(response, 'Eagle is not responding. Make sure Eagle is running.');
  }

  function normalizeFolderTreeData(data) {
    if (Array.isArray(data)) return data;
    if (Array.isArray(data?.items)) return data.items;
    if (Array.isArray(data?.data)) return data.data;
    return [];
  }

  function flattenFolders(tree, parentPath = '') {
    const source = Array.isArray(tree) ? tree : [tree];
    const out = [];

    source.filter(Boolean).forEach((folder) => {
      const folderId = String(folder.id || folder.folderId || '').trim();
      const folderName = normalizeText(folder.name || folder.folderName || '');
      if (!folderId || !folderName) return;

      const path = parentPath ? `${parentPath} / ${folderName}` : folderName;
      out.push({ id: folderId, name: folderName, path });

      if (Array.isArray(folder.children) && folder.children.length) {
        out.push(...flattenFolders(folder.children, path));
      }
    });

    return out;
  }

  async function fetchEagleFolders() {
    const response = await gmRequest({
      method: 'GET',
      url: `${CONFIG.eagleBaseUrl}/api/v2/folder/get?offset=0&limit=5000`
    });
    const data = unwrapJSend(response, 'Failed to load Eagle folders.');
    return flattenFolders(normalizeFolderTreeData(data));
  }

  function renderFolderOptions() {
    const select = document.getElementById(UI.folderSelectId);
    if (!select) return;

    const currentValue = normalizeText(state.selectedFolderId);
    select.innerHTML = '';

    const rootOption = document.createElement('option');
    rootOption.value = '';
    rootOption.textContent = 'Root Library';
    select.appendChild(rootOption);

    state.folderOptions.forEach((folder) => {
      const option = document.createElement('option');
      option.value = folder.id;
      option.textContent = folder.path;
      select.appendChild(option);
    });

    const hasCurrentValue = state.folderOptions.some((folder) => folder.id === currentValue);
    if (currentValue && !hasCurrentValue) {
      const fallbackOption = document.createElement('option');
      fallbackOption.value = currentValue;
      fallbackOption.textContent = `[Saved] ${currentValue}`;
      select.appendChild(fallbackOption);
    }
    select.value = currentValue;
  }

  async function loadFolderOptions() {
    const select = document.getElementById(UI.folderSelectId);
    const refreshButton = document.getElementById(UI.folderRefreshButtonId);
    if (select) {
      select.disabled = true;
      select.innerHTML = '';
      const loadingOption = document.createElement('option');
      loadingOption.value = '';
      loadingOption.textContent = 'Loading folders...';
      select.appendChild(loadingOption);
    }
    if (refreshButton) {
      refreshButton.disabled = true;
    }

    try {
      await ensureEagleAvailable();
      state.folderOptions = await fetchEagleFolders();
      renderFolderOptions();
    } catch (error) {
      console.warn('[Ex Gallery Tags to Eagle] Failed to load folders:', error);
      state.folderOptions = [];
      renderFolderOptions();
    } finally {
      if (select) select.disabled = false;
      if (refreshButton) refreshButton.disabled = false;
    }
  }

  function getTargetFolderIds() {
    const selectedFolderId = normalizeText(state.selectedFolderId);
    if (selectedFolderId) return [selectedFolderId];
    return Array.isArray(CONFIG.folderIds)
      ? CONFIG.folderIds.map(String).map(normalizeText).filter(Boolean)
      : [];
  }

  function normalizeTagGroupList(data) {
    if (Array.isArray(data)) return data;
    if (Array.isArray(data?.items)) return data.items;
    if (Array.isArray(data?.data)) return data.data;
    return [];
  }

  function mergeUniqueTags(...tagLists) {
    return Array.from(new Set(tagLists
      .flatMap((tags) => Array.isArray(tags) ? tags : [])
      .map(normalizeTagText)
      .filter(Boolean)));
  }

  function getTagGroupDefinition(groupKey) {
    if (groupKey === 'c') {
      const suffix = getCurrentTagSuffix();
      if (!suffix) {
        return {
          label: 'Suffix group',
          shortLabel: 'Suffix',
          emptyLabel: 'No Suffix Group',
          syncTargetLabel: 'suffix tags'
        };
      }
      return {
        label: `${suffix} group`,
        shortLabel: suffix,
        emptyLabel: `No ${suffix} Group`,
        syncTargetLabel: `${suffix} tags`
      };
    }
    return TAG_GROUP_DEFINITIONS[groupKey] || {
      label: `${groupKey} group`,
      shortLabel: groupKey,
      emptyLabel: `No ${groupKey} Group`,
      syncTargetLabel: `${groupKey} tags`
    };
  }

  function getSelectedTagGroupId(groupKey) {
    return normalizeText(state.selectedTagGroupIds?.[groupKey] || '');
  }

  function setSelectedTagGroupId(groupKey, groupId) {
    if (!state.selectedTagGroupIds) {
      state.selectedTagGroupIds = {};
    }
    state.selectedTagGroupIds[groupKey] = normalizeText(groupId);
    saveSelectedTagGroupId(groupKey, state.selectedTagGroupIds[groupKey]);
  }

  function resolveSelectedTagGroup(groupKey) {
    const rawValue = getSelectedTagGroupId(groupKey);
    if (!rawValue) {
      return { rawValue: '', id: '', name: '', group: null, matched: false };
    }

    const groupById = state.tagGroupOptions.find((group) => String(group.id || '') === rawValue);
    if (groupById) {
      return {
        rawValue,
        id: String(groupById.id || ''),
        name: normalizeText(groupById.name || String(groupById.id || '')),
        group: groupById,
        matched: true
      };
    }

    const groupByName = state.tagGroupOptions.find((group) => normalizeText(group.name || '') === rawValue);
    if (groupByName) {
      return {
        rawValue,
        id: String(groupByName.id || ''),
        name: normalizeText(groupByName.name || String(groupByName.id || '')),
        group: groupByName,
        matched: true
      };
    }

    return { rawValue, id: rawValue, name: rawValue, group: null, matched: false };
  }

  function syncSelectedTagGroupState(groupKey) {
    const resolved = resolveSelectedTagGroup(groupKey);
    if (resolved.matched && resolved.id && resolved.id !== getSelectedTagGroupId(groupKey)) {
      setSelectedTagGroupId(groupKey, resolved.id);
    }
    return resolved;
  }

  function renderTagGroupOptionsFor(groupKey) {
    const select = document.getElementById(UI.tagGroupSelectIds[groupKey]);
    if (!select) return;

    const definition = getTagGroupDefinition(groupKey);
    const resolvedSelection = syncSelectedTagGroupState(groupKey);
    const currentValue = normalizeText(resolvedSelection.id || resolvedSelection.rawValue);
    select.innerHTML = '';

    const disabledOption = document.createElement('option');
    disabledOption.value = '';
    disabledOption.textContent = definition.emptyLabel;
    select.appendChild(disabledOption);

    state.tagGroupOptions.forEach((group) => {
      const option = document.createElement('option');
      option.value = String(group.id || '');
      option.textContent = normalizeText(group.name || String(group.id || ''));
      select.appendChild(option);
    });

    const hasCurrentValue = state.tagGroupOptions.some((group) => String(group.id || '') === currentValue);
    if (currentValue && !hasCurrentValue) {
      const fallbackOption = document.createElement('option');
      fallbackOption.value = currentValue;
      fallbackOption.textContent = `[Saved] ${currentValue}`;
      select.appendChild(fallbackOption);
    }

    select.value = currentValue;
  }

  function renderTagGroupOptions() {
    TAG_GROUP_ORDER.forEach((groupKey) => {
      renderTagGroupOptionsFor(groupKey);
    });
  }

  function renderTagGroupLabels() {
    TAG_GROUP_ORDER.forEach((groupKey) => {
      const label = document.querySelector(`[data-ex-eagle-tag-group-label="${groupKey}"]`);
      if (!label) return;
      label.textContent = getTagGroupDefinition(groupKey).shortLabel;
    });
  }

  async function fetchTagGroups() {
    const response = await gmRequest({
      method: 'GET',
      url: `${CONFIG.eagleBaseUrl}/api/v2/tagGroup/get`
    });
    const data = unwrapJSend(response, 'Failed to load Eagle tag groups.');
    return normalizeTagGroupList(data);
  }

  async function loadTagGroupOptions() {
    TAG_GROUP_ORDER.forEach((groupKey) => {
      const select = document.getElementById(UI.tagGroupSelectIds[groupKey]);
      const refreshButton = document.getElementById(UI.tagGroupRefreshButtonIds[groupKey]);
      if (select) {
        select.disabled = true;
        select.innerHTML = '';
        const loadingOption = document.createElement('option');
        loadingOption.value = '';
        loadingOption.textContent = 'Loading tag groups...';
        select.appendChild(loadingOption);
      }
      if (refreshButton) {
        refreshButton.disabled = true;
      }
    });

    try {
      await ensureEagleAvailable();
      state.tagGroupOptions = await fetchTagGroups();
      renderTagGroupOptions();
      refreshResolvedCharacterTagValues();
    } catch (error) {
      console.warn('[Ex Gallery Tags to Eagle] Failed to load tag groups:', error);
      state.tagGroupOptions = [];
      renderTagGroupOptions();
    } finally {
      TAG_GROUP_ORDER.forEach((groupKey) => {
        const select = document.getElementById(UI.tagGroupSelectIds[groupKey]);
        const refreshButton = document.getElementById(UI.tagGroupRefreshButtonIds[groupKey]);
        if (select) select.disabled = false;
        if (refreshButton) refreshButton.disabled = false;
      });
    }
  }

  async function getTagGroupById(groupId) {
    const normalizedGroupId = normalizeText(groupId);
    if (!normalizedGroupId) return null;

    let existingGroup = state.tagGroupOptions.find((group) => String(group.id || '') === normalizedGroupId);
    if (existingGroup) return existingGroup;

    state.tagGroupOptions = await fetchTagGroups();
    renderTagGroupOptions();
    existingGroup = state.tagGroupOptions.find((group) => String(group.id || '') === normalizedGroupId);
    return existingGroup || null;
  }

  function updateTagGroupInState(nextGroup) {
    if (!nextGroup?.id) return;
    const nextId = String(nextGroup.id);
    const index = state.tagGroupOptions.findIndex((group) => String(group.id || '') === nextId);
    if (index >= 0) {
      state.tagGroupOptions[index] = nextGroup;
    } else {
      state.tagGroupOptions.push(nextGroup);
    }
  }

  async function addTagsToTagGroup(groupId, tags) {
    const normalizedTags = mergeUniqueTags(tags);
    if (!groupId || !normalizedTags.length) return;

    const response = await gmRequest({
      method: 'POST',
      url: `${CONFIG.eagleBaseUrl}/api/v2/tagGroup/addTags`,
      data: {
        groupId: String(groupId),
        tags: normalizedTags
      }
    });
    const updatedGroup = unwrapJSend(response, 'Failed to add tags to Eagle tag group.');
    if (updatedGroup && typeof updatedGroup === 'object') {
      updateTagGroupInState(updatedGroup);
      renderTagGroupOptions();
      return;
    }

    const existingGroup = await getTagGroupById(groupId);
    if (!existingGroup) {
      throw new Error(`Tag group "${groupId}" was not found after addTags.`);
    }
  }

  function getSelectedTagValuesForNamespaces(selectedEntries, namespaces) {
    const namespaceSet = new Set((Array.isArray(namespaces) ? namespaces : [namespaces])
      .map(canonicalizeNamespace));
    return mergeUniqueTags((Array.isArray(selectedEntries) ? selectedEntries : [])
      .filter((tag) => namespaceSet.has(canonicalizeNamespace(tag.namespace)))
      .map((tag) => tag.value));
  }

  function getSelectedSuffixManagedTagValues(selectedEntries) {
    return mergeUniqueTags((Array.isArray(selectedEntries) ? selectedEntries : [])
      .filter((tag) => !SUFFIX_EXEMPT_NAMESPACES.has(canonicalizeNamespace(tag.namespace)))
      .map((tag) => tag.value));
  }

  function sanitizeParodyForCharacterContext(value) {
    return normalizeTagText(String(value || '').replace(/[!\uFF01]+/g, ''));
  }

  function getProtectedCharacterTagGroups() {
    return (Array.isArray(state.tagGroupOptions) ? state.tagGroupOptions : [])
      .filter((group) => normalizeText(group?.color).toLowerCase() === 'orange');
  }

  function buildProtectedGroupNameCandidates(groupName) {
    const normalizedName = sanitizeParodyForCharacterContext(groupName);
    if (!normalizedName) return [];

    const candidates = new Set([normalizedName]);
    const withoutSeries = normalizedName.replace(/\s*系列\s*$/g, '');
    if (withoutSeries) {
      candidates.add(withoutSeries);
    }

    normalizedName
      .split(/[:\uFF1A/]/)
      .map(sanitizeParodyForCharacterContext)
      .filter((part) => part && part.length >= 2)
      .forEach((part) => candidates.add(part));

    return Array.from(candidates);
  }

  function findProtectedCharacterTagGroup(parodyTags) {
    const normalizedParodies = mergeUniqueTags(parodyTags).map(sanitizeParodyForCharacterContext);
    if (!normalizedParodies.length) return null;

    for (const group of getProtectedCharacterTagGroups()) {
      const candidates = buildProtectedGroupNameCandidates(group?.name);
      for (const parody of normalizedParodies) {
        const matchedCandidate = candidates.find((candidate) => parody.includes(candidate) || candidate.includes(parody));
        if (matchedCandidate) {
          return group;
        }
      }
    }

    return null;
  }

  function extractCharacterBaseName(value) {
    return normalizeTagText(String(value || '').replace(/\s*[((][^()()]+[))]\s*$/, ''));
  }

  function findExactTagInGroup(group, tagName) {
    const normalizedTarget = normalizeTagText(tagName);
    if (!normalizedTarget) return '';

    return normalizeTagText((Array.isArray(group?.tags) ? group.tags : [])
      .find((tag) => normalizeTagText(tag) === normalizedTarget) || '');
  }

  function findExactTagAcrossGroups(tagName) {
    const normalizedTarget = normalizeTagText(tagName);
    if (!normalizedTarget) return '';

    for (const group of (Array.isArray(state.tagGroupOptions) ? state.tagGroupOptions : [])) {
      const exactTag = findExactTagInGroup(group, normalizedTarget);
      if (exactTag) {
        return exactTag;
      }
    }

    return '';
  }

  function findMatchingCharacterTagInGroup(group, characterTag) {
    const normalizedCharacter = extractCharacterBaseName(characterTag);
    if (!normalizedCharacter) return '';

    let bestTag = '';
    let bestScore = -1;
    (Array.isArray(group?.tags) ? group.tags : []).forEach((tag) => {
      const normalizedTag = normalizeTagText(tag);
      const baseName = extractCharacterBaseName(normalizedTag);
      if (!normalizedTag || !baseName) return;

      let score = -1;
      if (normalizedTag === normalizedCharacter) {
        score = 120;
      } else if (baseName === normalizedCharacter) {
        score = 100;
      } else if (normalizedTag.includes(normalizedCharacter)) {
        score = 85;
      } else if (baseName.includes(normalizedCharacter) || normalizedCharacter.includes(baseName)) {
        score = 70;
      }

      if (score > bestScore) {
        bestScore = score;
        bestTag = normalizedTag;
      }
    });

    return bestScore >= 70 ? bestTag : '';
  }

  function resolveCharacterTagValue(characterTag, parodyTags, mode = 'item') {
    const normalizedCharacter = extractCharacterBaseName(characterTag);
    if (!normalizedCharacter) {
      return { value: '', source: 'empty', protectedGroup: null };
    }

    const normalizedParodyTags = mergeUniqueTags(parodyTags)
      .map(sanitizeParodyForCharacterContext)
      .filter(Boolean);
    if (!normalizedParodyTags.length) {
      return { value: normalizedCharacter, source: 'raw', protectedGroup: null };
    }

    const [primaryParody] = normalizedParodyTags;
    const generatedTag = `${normalizedCharacter}(${primaryParody})`;
    const protectedGroup = findProtectedCharacterTagGroup(parodyTags);

    if (protectedGroup) {
      const exactProtectedTag = findExactTagInGroup(protectedGroup, generatedTag);
      if (exactProtectedTag) {
        return { value: exactProtectedTag, source: 'protected-exact', protectedGroup };
      }

      const matchedProtectedTag = findMatchingCharacterTagInGroup(protectedGroup, normalizedCharacter);
      if (matchedProtectedTag) {
        return { value: matchedProtectedTag, source: 'protected-match', protectedGroup };
      }

      return mode === 'group'
        ? { value: '', source: 'protected-miss', protectedGroup }
        : { value: normalizedCharacter, source: 'protected-raw', protectedGroup };
    }

    const exactExistingTag = findExactTagAcrossGroups(generatedTag);
    if (exactExistingTag) {
      return { value: exactExistingTag, source: 'existing-exact', protectedGroup: null };
    }

    return { value: generatedTag, source: 'generated', protectedGroup: null };
  }

  function buildCharacterTagSyncResult(selectedEntries, mode = 'group') {
    const characterEntries = (Array.isArray(selectedEntries) ? selectedEntries : [])
      .filter((tag) => canonicalizeNamespace(tag.namespace) === 'character');
    if (!characterEntries.length) {
      return { tags: [], replacements: [], skippedReason: '' };
    }

    const parodyTags = getSelectedTagValuesForNamespaces(selectedEntries, 'parody');
    const normalizedParodyTags = parodyTags
      .map(sanitizeParodyForCharacterContext)
      .filter(Boolean);
    const [primaryParody] = normalizedParodyTags;
    if (!parodyTags.length) {
      return {
        tags: mode === 'item' ? characterTags : [],
        replacements: [],
        skippedReason: ''
      };
    }

    const protectedGroup = findProtectedCharacterTagGroup(parodyTags);
    const resolvedTags = [];
    const replacements = [];

    characterEntries.forEach((tag) => {
      const currentValue = normalizeTagText(tag?.value || tag?.defaultValue || tag?.rawText);
      const normalizedCharacter = extractCharacterBaseName(currentValue || tag?.defaultValue || tag?.rawText);
      const manualOverride = getManualTagOverrideValue(tag);

      if (manualOverride) {
        resolvedTags.push(manualOverride);
        appendCharacterReplacementAliases(replacements, normalizeTagText(tag?.defaultValue) || normalizedCharacter, manualOverride, primaryParody);
        return;
      }

      const resolved = resolveCharacterTagValue(currentValue, parodyTags, mode);
      const nextValue = normalizeTagText(resolved?.value);

      if (nextValue) {
        resolvedTags.push(nextValue);
        appendCharacterReplacementAliases(replacements, normalizeTagText(tag?.defaultValue) || normalizedCharacter, nextValue, primaryParody);
        return;
      }

      if (mode === 'item' && normalizedCharacter) {
        resolvedTags.push(normalizedCharacter);
      }
    });

    return {
      tags: mergeUniqueTags(resolvedTags),
      replacements: Array.from(new Map(replacements.map((item) => [`${item.from}=>${item.to}`, item])).values()),
      skippedReason: !resolvedTags.length && protectedGroup
        ? `matched protected group "${normalizeText(protectedGroup?.name || '')}"`
        : ''
    };
  }

  function buildCharacterGroupTags(selectedEntries) {
    const characterResult = buildCharacterTagSyncResult(selectedEntries, 'group');
    return {
      tags: characterResult.tags,
      skippedReason: characterResult.skippedReason
    };
  }

  function buildItemTagSyncResult(selectedEntries) {
    const characterResult = buildCharacterTagSyncResult(selectedEntries, 'item');
    const nonCharacterTags = mergeUniqueTags((Array.isArray(selectedEntries) ? selectedEntries : [])
      .filter((tag) => canonicalizeNamespace(tag.namespace) !== 'character')
      .map((tag) => tag.value));

    return {
      tags: mergeUniqueTags(nonCharacterTags, characterResult.tags),
      replacements: characterResult.replacements
    };
  }

  function appendCharacterReplacementAliases(replacements, baseName, nextValue, primaryParody) {
    const normalizedBaseName = normalizeTagText(baseName);
    const normalizedNextValue = normalizeTagText(nextValue);
    const normalizedParody = sanitizeParodyForCharacterContext(primaryParody);
    if (!normalizedBaseName || !normalizedNextValue) return;

    if (normalizedBaseName !== normalizedNextValue) {
      replacements.push({ from: normalizedBaseName, to: normalizedNextValue });
    }

    if (normalizedParody) {
      const generatedCharacterTag = `${normalizedBaseName}(${normalizedParody})`;
      if (generatedCharacterTag !== normalizedNextValue && generatedCharacterTag !== normalizedBaseName) {
        replacements.push({ from: generatedCharacterTag, to: normalizedNextValue });
      }
    }
  }

  function applyResolvedCharacterValuesToEntries(entries) {
    const sourceEntries = Array.isArray(entries) ? entries : [];
    if (!sourceEntries.length) return 0;

    const parodyTags = getSelectedTagValuesForNamespaces(sourceEntries, 'parody');
    if (!parodyTags.length) return 0;

    let changedCount = 0;
    sourceEntries.forEach((tag) => {
      if (canonicalizeNamespace(tag.namespace) !== 'character') return;
      if (hasManualTagOverride(tag)) return;

      const currentValue = normalizeTagText(tag.value || tag.defaultValue || tag.rawText);
      const resolved = resolveCharacterTagValue(currentValue, parodyTags, 'item');
      const nextValue = normalizeTagText(resolved?.value);
      if (!nextValue || nextValue === currentValue) return;

      tag.value = nextValue;
      changedCount += 1;
    });

    return changedCount;
  }

  function refreshResolvedCharacterTagValues(options = {}) {
    if (!state.tagGroupOptions.length || !state.extractedTags.length) {
      return 0;
    }

    const targetEntries = options.selectedOnly
      ? getSelectedTagEntries()
      : state.extractedTags;
    const changedCount = applyResolvedCharacterValuesToEntries(targetEntries);
    if (changedCount > 0) {
      renderTagList();
    }
    return changedCount;
  }

  function buildTagsForConfiguredGroup(groupKey, selectedEntries) {
    switch (groupKey) {
      case 'c':
        {
          const suffix = getCurrentTagSuffix();
          if (!suffix) {
            return {
              tags: getSelectedSuffixManagedTagValues(selectedEntries),
              skippedReason: ''
            };
          }
          const normalizedSuffix = suffix.toUpperCase();
        return {
          tags: mergeUniqueTags((Array.isArray(selectedEntries) ? selectedEntries : [])
            .map((tag) => tag.value)
            .filter((tag) => normalizeTagText(tag).toUpperCase().endsWith(normalizedSuffix))),
          skippedReason: ''
        };
        }
      case 'parody':
        return {
          tags: getSelectedTagValuesForNamespaces(selectedEntries, 'parody'),
          skippedReason: ''
        };
      case 'character':
        return buildCharacterGroupTags(selectedEntries);
      case 'group':
        return {
          tags: getSelectedTagValuesForNamespaces(selectedEntries, 'group'),
          skippedReason: ''
        };
      case 'artist':
        return {
          tags: getSelectedTagValuesForNamespaces(selectedEntries, 'artist'),
          skippedReason: ''
        };
      default:
        return { tags: [], skippedReason: '' };
    }
  }

  async function syncConfiguredTagGroup(groupKey, selectedEntries) {
    const resolvedSelection = syncSelectedTagGroupState(groupKey);
    const groupId = normalizeText(resolvedSelection.id);
    const definition = getTagGroupDefinition(groupKey);
    if (!groupId) {
      return { key: groupKey, label: definition.shortLabel, enabled: false, count: 0, name: '', id: '', skippedReason: '' };
    }

    if (state.tagGroupOptions.length && !resolvedSelection.matched) {
      throw new Error(`Configured ${definition.label} "${resolvedSelection.rawValue}" was not found. Re-select the group.`);
    }

    const buildResult = buildTagsForConfiguredGroup(groupKey, selectedEntries);
    const tags = mergeUniqueTags(buildResult?.tags || []);
    if (!tags.length) {
      return {
        key: groupKey,
        label: definition.shortLabel,
        enabled: true,
        count: 0,
        name: resolvedSelection.name || groupId,
        id: groupId,
        skippedReason: normalizeText(buildResult?.skippedReason || '')
      };
    }

    await addTagsToTagGroup(groupId, tags);
    return {
      key: groupKey,
      label: definition.shortLabel,
      enabled: true,
      count: tags.length,
      name: resolvedSelection.name || groupId,
      id: groupId,
      skippedReason: ''
    };
  }

  async function syncConfiguredTagGroups(selectedEntries) {
    const results = [];
    const errors = [];

    for (const groupKey of TAG_GROUP_ORDER) {
      try {
        results.push(await syncConfiguredTagGroup(groupKey, selectedEntries));
      } catch (error) {
        errors.push({
          key: groupKey,
          label: getTagGroupDefinition(groupKey).shortLabel,
          message: error?.message || 'unknown error'
        });
      }
    }

    return { results, errors };
  }

  function summarizeGroupSyncResults(results, errors) {
    const successful = (Array.isArray(results) ? results : [])
      .filter((result) => result.enabled && result.count > 0)
      .map((result) => `${result.label}: ${result.count} -> ${result.name}`);
    const skipped = (Array.isArray(results) ? results : [])
      .filter((result) => result.enabled && !result.count && result.skippedReason)
      .map((result) => `${result.label}: ${result.skippedReason}`);
    const failed = (Array.isArray(errors) ? errors : [])
      .map((error) => `${error.label}: ${error.message}`);

    return {
      successMessage: successful.length ? ` Group sync: ${successful.join('; ')}.` : '',
      skippedMessage: skipped.length ? ` Group skipped: ${skipped.join('; ')}.` : '',
      errorMessage: failed.length ? ` Group sync failed: ${failed.join('; ')}.` : ''
    };
  }

  function normalizeItemList(data) {
    return Array.isArray(data)
      ? data
      : Array.isArray(data?.items)
        ? data.items
        : Array.isArray(data?.data)
          ? data.data
          : [];
  }

  function buildItemQueryPayload(filters = {}, limit = 10) {
    const payload = {
      limit,
      fields: ['id', 'name', 'tags', 'url', 'folders']
    };

    Object.entries(filters || {}).forEach(([key, value]) => {
      if (value == null) return;
      if (typeof value === 'string' && !normalizeText(value)) return;
      payload[key] = value;
    });

    return payload;
  }

  async function findExistingItemByUrl(pageUrl) {
    try {
      const response = await gmRequest({
        method: 'POST',
        url: `${CONFIG.eagleBaseUrl}/api/v2/item/get`,
        data: buildItemQueryPayload({ url: pageUrl }, 10)
      });
      const data = unwrapJSend(response, 'Failed to query Eagle items.');
      const items = normalizeItemList(data);
      return items[0] || null;
    } catch (error) {
      console.warn('[Ex Gallery Tags to Eagle] URL lookup failed, fallback to add:', error);
      return null;
    }
  }

  async function findExistingItemById(itemId) {
    const normalizedItemId = normalizeText(itemId);
    if (!normalizedItemId) return null;

    try {
      const response = await gmRequest({
        method: 'POST',
        url: `${CONFIG.eagleBaseUrl}/api/v2/item/get`,
        data: buildItemQueryPayload({ id: normalizedItemId }, 1)
      });
      const data = unwrapJSend(response, 'Failed to query Eagle item.');
      const items = normalizeItemList(data);
      return items[0] || null;
    } catch (error) {
      console.warn('[Ex Gallery Tags to Eagle] ID lookup failed:', error);
      return null;
    }
  }

  function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  async function waitForItemByUrl(pageUrl, attempts = 5, delayMs = 300) {
    for (let i = 0; i < attempts; i++) {
      const item = await findExistingItemByUrl(pageUrl);
      if (item?.id) return item;
      if (i < attempts - 1) {
        await sleep(delayMs);
      }
    }
    return null;
  }

  async function waitForItemById(itemId, attempts = 8, delayMs = 300) {
    for (let i = 0; i < attempts; i++) {
      const item = await findExistingItemById(itemId);
      if (item?.id) return item;
      if (i < attempts - 1) {
        await sleep(delayMs);
      }
    }
    return null;
  }

  async function ensureBookmarkTitle(itemOrId, meta, options = {}) {
    const targetTitle = normalizeText(meta?.title);
    const itemId = normalizeText(typeof itemOrId === 'string' ? itemOrId : itemOrId?.id);
    let currentItem = itemOrId && typeof itemOrId === 'object' ? itemOrId : null;
    if (!targetTitle || !itemId) {
      return currentItem;
    }

    const initialDelayMs = Math.max(0, Number(options.initialDelayMs ?? 0) || 0);
    const retryDelayMs = Math.max(0, Number(options.retryDelayMs ?? CONFIG.bookmarkMetadataRetryDelayMs) || 0);
    const retryCount = Math.max(0, Number(options.retryCount ?? CONFIG.bookmarkMetadataRetryCount) || 0);

    if (initialDelayMs > 0) {
      await sleep(initialDelayMs);
    }

    for (let attempt = 0; attempt <= retryCount; attempt++) {
      currentItem = await findExistingItemById(itemId) || currentItem;
      if (normalizeText(currentItem?.name) === targetTitle) {
        return currentItem;
      }

      if (currentItem?.id) {
        await updateBookmark(currentItem, meta);
      }

      if (attempt < retryCount && retryDelayMs > 0) {
        await sleep(retryDelayMs);
      }
    }

    return await findExistingItemById(itemId) || currentItem;
  }

  async function addBookmark(meta, options = {}) {
    const item = {
      name: meta.title || meta.pageUrl,
      tags: meta.tags,
      bookmarkURL: meta.pageUrl,
      website: meta.pageUrl
    };

    const targetFolderIds = getTargetFolderIds();
    if (targetFolderIds.length) {
      item.folders = targetFolderIds;
    }

    const response = await gmRequest({
      method: 'POST',
      url: `${CONFIG.eagleBaseUrl}/api/v2/item/add`,
      data: { items: [item] }
    });
    const created = unwrapJSend(response, 'Failed to add bookmark to Eagle.');
    const createdId = normalizeText(created?.ids?.[0]);

    let createdItem = createdId
      ? await waitForItemById(createdId, 10, 400)
      : await waitForItemByUrl(meta.pageUrl, 10, 400);
    if (!createdItem?.id) {
      createdItem = await waitForItemByUrl(meta.pageUrl, 10, 400);
    }
    if (createdItem?.id && !options.skipTitleFinalization) {
      createdItem = await ensureBookmarkTitle(createdItem, meta, {
        initialDelayMs: CONFIG.bookmarkMetadataSettleDelayMs
      }) || createdItem;
    }
    return createdItem;
  }

  async function updateBookmark(existingItem, meta) {
    const replacementMap = new Map((Array.isArray(meta.tagReplacements) ? meta.tagReplacements : [])
      .map((item) => [normalizeTagText(item?.from), normalizeTagText(item?.to)]));
    const existingTags = (Array.isArray(existingItem.tags) ? existingItem.tags : [])
      .map(normalizeTagText)
      .filter(Boolean)
      .filter((tag) => !replacementMap.has(tag));
    const mergedTags = Array.from(new Set([...existingTags, ...meta.tags]));
    const payload = {
      id: existingItem.id,
      name: meta.title || existingItem.name || meta.pageUrl,
      tags: mergedTags
    };
    const targetFolderIds = getTargetFolderIds();
    if (targetFolderIds.length) {
      const existingFolders = Array.isArray(existingItem.folders)
        ? existingItem.folders.map(String).map(normalizeText).filter(Boolean)
        : [];
      payload.folders = Array.from(new Set([...existingFolders, ...targetFolderIds]));
    }

    try {
      const response = await gmRequest({
        method: 'POST',
        url: `${CONFIG.eagleBaseUrl}/api/v2/item/update`,
        data: payload
      });
      return unwrapJSend(response, 'Failed to update Eagle tags.');
    } catch (error) {
      if (!payload.folders) throw error;

      const fallbackResponse = await gmRequest({
        method: 'POST',
        url: `${CONFIG.eagleBaseUrl}/api/v2/item/update`,
        data: {
          id: payload.id,
          name: payload.name,
          tags: payload.tags
        }
      });
      return unwrapJSend(fallbackResponse, 'Failed to update Eagle tags.');
    }
  }

  function setStatus(message, tone) {
    const status = document.getElementById(UI.statusId);
    if (!status) return;

    status.textContent = message;
    status.style.color = tone === 'error'
      ? '#ffb3b3'
      : tone === 'success'
        ? '#b9f6ca'
        : '#d7d7d7';
  }

  function getSelectedTagEntries() {
    return state.extractedTags.filter((tag) => state.selectedTags.has(tag.id));
  }

  function getSelectedTags() {
    const selected = [];
    const seen = new Set();

    getSelectedTagEntries().forEach((tag) => {
      const value = normalizeTagText(tag.value);
      if (!value || seen.has(value)) return;
      seen.add(value);
      selected.push(value);
    });

    return selected;
  }

  function updateSummary() {
    const summary = document.getElementById(UI.summaryId);
    if (!summary) return;

    const selectedCount = getSelectedTags().length;
    const totalCount = state.extractedTags.length;
    summary.textContent = totalCount
      ? `Selected ${selectedCount} / ${totalCount}`
      : 'No tags loaded';
  }

  function renderTagList() {
    const list = document.getElementById(UI.listId);
    if (!list) return;

    list.innerHTML = '';

    if (!state.extractedTags.length) {
      const empty = document.createElement('div');
      empty.textContent = 'No tags found on this page.';
      empty.style.cssText = 'color:#b8b8b8;padding:8px 4px;text-align:center;';
      list.appendChild(empty);
      updateSummary();
      return;
    }

    const fragment = document.createDocumentFragment();
    state.extractedTags.forEach((tag) => {
      const row = document.createElement('div');
      row.style.cssText = [
        'display:flex',
        'align-items:center',
        'gap:8px',
        'padding:4px 2px',
        'color:#f1f1f1',
        'word-break:break-word'
      ].join(';');

      const checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.checked = state.selectedTags.has(tag.id);
      checkbox.addEventListener('change', () => {
        if (checkbox.checked) {
          state.selectedTags.add(tag.id);
        } else {
          state.selectedTags.delete(tag.id);
        }
        updateSummary();
      });

      const namespace = document.createElement('span');
      namespace.textContent = tag.namespace;
      namespace.style.cssText = [
        'min-width:58px',
        'padding:2px 6px',
        'border-radius:999px',
        'background:#4a2e2e',
        'color:#ffd7d7',
        'font-size:11px',
        'text-align:center',
        'user-select:none'
      ].join(';');

      const input = document.createElement('input');
      input.type = 'text';
      input.value = normalizeTagText(tag.value);
      input.spellcheck = false;
      input.style.cssText = [
        'flex:1',
        'min-width:0',
        'padding:5px 6px',
        'border:1px solid #666',
        'border-radius:4px',
        'background:#1d1f23',
        'color:#f1f1f1'
      ].join(';');
      input.addEventListener('input', () => {
        const sanitizedValue = normalizeTagText(input.value);
        if (input.value !== sanitizedValue) {
          input.value = sanitizedValue;
        }
        tag.value = sanitizedValue;
        setPersistedTagValue(tag.id, tag.value, tag.defaultValue);
        updateSummary();
      });

      row.appendChild(checkbox);
      row.appendChild(namespace);
      row.appendChild(input);
      fragment.appendChild(row);
    });

    list.appendChild(fragment);
    updateSummary();
  }

  function selectAllTags() {
    state.selectedTags = new Set(state.extractedTags.map((tag) => tag.id));
    renderTagList();
  }

  function clearAllTags() {
    state.selectedTags = new Set();
    renderTagList();
  }

  function refreshTagState(options = {}) {
    const preserveSelection = options.preserveSelection !== false;
    const previousState = preserveSelection
      ? new Map(state.extractedTags.map((tag) => [tag.id, {
        selected: state.selectedTags.has(tag.id),
        value: tag.value
      }]))
      : new Map();
    const hadPreviousTags = state.extractedTags.length > 0;
    const extractedTags = collectGalleryTags().map((tag) => {
      const previous = previousState.get(tag.id);
      return previous
        ? { ...tag, value: normalizeTagText(previous.value) || getPersistedTagValue(tag.id) || tag.defaultValue }
        : tag;
    });

    state.extractedTags = extractedTags;
    if (preserveSelection && hadPreviousTags) {
      state.selectedTags = new Set(
        extractedTags
          .filter((tag) => previousState.get(tag.id)?.selected)
          .map((tag) => tag.id)
      );
    } else {
      state.selectedTags = new Set(extractedTags.map((tag) => tag.id));
    }

    renderTagList();
    return extractedTags;
  }

  function setBusy(isBusy) {
    const ids = [
      UI.buttonId,
      UI.refreshButtonId,
      UI.selectAllButtonId,
      UI.clearButtonId,
      UI.folderRefreshButtonId,
      UI.tagSuffixApplyButtonId,
      ...TAG_GROUP_ORDER.map((groupKey) => UI.tagGroupRefreshButtonIds[groupKey])
    ];

    ids.forEach((id) => {
      const button = document.getElementById(id);
      if (!button) return;
      button.disabled = isBusy;
      button.style.opacity = isBusy ? '0.75' : '1';
      button.style.cursor = isBusy ? 'wait' : 'pointer';
    });

    const folderSelect = document.getElementById(UI.folderSelectId);
    if (folderSelect) {
      folderSelect.disabled = isBusy;
    }

    const tagSuffixInput = document.getElementById(UI.tagSuffixInputId);
    if (tagSuffixInput) {
      tagSuffixInput.disabled = isBusy;
    }

    TAG_GROUP_ORDER.forEach((groupKey) => {
      const tagGroupSelect = document.getElementById(UI.tagGroupSelectIds[groupKey]);
      if (tagGroupSelect) {
        tagGroupSelect.disabled = isBusy;
      }
    });
  }

  function handleRefreshClick() {
    try {
      const extractedTags = refreshTagState({ preserveSelection: true });
      refreshResolvedCharacterTagValues();
      setStatus(
        extractedTags.length
          ? `Loaded ${extractedTags.length} tags from page.`
          : 'No tags were found on this page, but bookmark import is still available.',
        extractedTags.length ? 'success' : 'info'
      );
    } catch (error) {
      console.error('[Ex Gallery Tags to Eagle]', error);
      state.extractedTags = [];
      state.selectedTags = new Set();
      renderTagList();
      setStatus(error?.message || 'Failed to read tags from page.', 'error');
    }
  }

  async function handleSyncClick() {
    try {
      setBusy(true);
      setStatus('Preparing selected tags...', 'info');

      if (!state.extractedTags.length) {
        refreshTagState({ preserveSelection: false });
      }

      const hasPageTags = state.extractedTags.length > 0;

      await ensureEagleAvailable();
      if (!state.tagGroupOptions.length) {
        try {
          state.tagGroupOptions = await fetchTagGroups();
          renderTagGroupOptions();
        } catch (tagGroupError) {
          console.warn('[Ex Gallery Tags to Eagle] Failed to prefetch tag groups for sync:', tagGroupError);
        }
      }
      refreshResolvedCharacterTagValues({ selectedOnly: true });

      const selectedEntries = getSelectedTagEntries();
      if (!selectedEntries.length && hasPageTags) {
        throw new Error('No tags selected.');
      }

      const itemTagSync = buildItemTagSyncResult(selectedEntries);
      const meta = collectGalleryMeta(hasPageTags ? itemTagSync.tags : []);
      meta.tagReplacements = itemTagSync.replacements;

      setStatus(
        hasPageTags
          ? `Syncing ${meta.tags.length} selected tags to Eagle...`
          : 'Syncing bookmark without page tags...',
        'info'
      );

      let itemMessage = '';
      const existingItem = await findExistingItemByUrl(meta.pageUrl);
      if (existingItem?.id) {
        await updateBookmark(existingItem, meta);
        await ensureBookmarkTitle(existingItem, meta);
        itemMessage = hasPageTags
          ? `Updated Eagle item with ${meta.tags.length} selected tags.`
          : 'Updated Eagle item without page tags.';
      } else {
        await addBookmark(meta, { skipTitleFinalization: true });
        itemMessage = hasPageTags
          ? `Added bookmark to Eagle with ${meta.tags.length} selected tags. Click Sync again to finalize the title.`
          : 'Added bookmark to Eagle without page tags. Click Sync again to finalize the title.';
      }

      const groupSync = await syncConfiguredTagGroups(selectedEntries);
      if (groupSync.errors.length) {
        console.error('[Ex Gallery Tags to Eagle] Tag group sync failed:', groupSync.errors);
      }
      const groupSummary = summarizeGroupSyncResults(groupSync.results, groupSync.errors);
      setStatus(
        `${itemMessage}${groupSummary.successMessage}${groupSummary.skippedMessage}${groupSummary.errorMessage}`,
        groupSync.errors.length ? 'error' : 'success'
      );
    } catch (error) {
      console.error('[Ex Gallery Tags to Eagle]', error);
      setStatus(error?.message || 'Sync failed.', 'error');
    } finally {
      setBusy(false);
    }
  }

  function createControlLabel(text) {
    const label = document.createElement('span');
    label.textContent = text;
    label.style.cssText = [
      'min-width:58px',
      'color:#d7d7d7',
      'font-size:11px',
      'font-weight:600',
      'text-align:right',
      'user-select:none'
    ].join(';');
    return label;
  }

  function getCurrentTagGroupLabel(groupKey) {
    const currentValue = getSelectedTagGroupId(groupKey);
    if (!currentValue) return 'disabled';
    const selectedGroup = state.tagGroupOptions.find((group) => String(group.id || '') === currentValue);
    return normalizeText(selectedGroup?.name || currentValue) || currentValue;
  }

  function renderTagSuffixInput() {
    const input = document.getElementById(UI.tagSuffixInputId);
    if (!input) return;
    input.value = getCurrentTagSuffix();
    input.placeholder = '';
    input.title = 'Optional custom suffix for tags outside artist, character, parody, and group.';
  }

  function syncExtractedTagsToCurrentSuffix() {
    if (!state.extractedTags.length) {
      renderTagList();
      return;
    }

    state.extractedTags = state.extractedTags.map((tag) => {
      const hadManualOverride = hasManualTagOverride(tag);
      const nextDefaultValue = formatTag(tag.namespace, tag.rawText);
      const nextValue = hadManualOverride
        ? normalizeTagText(tag.value) || nextDefaultValue
        : nextDefaultValue;

      setPersistedTagValue(tag.id, nextValue, nextDefaultValue);
      return {
        ...tag,
        defaultValue: nextDefaultValue,
        value: nextValue
      };
    });

    renderTagList();
  }

  function handleTagSuffixApply() {
    const input = document.getElementById(UI.tagSuffixInputId);
    if (!input) return;

    const requestedSuffix = sanitizeTagSuffix(input.value);
    const previousSuffix = getCurrentTagSuffix();
    const nextSuffix = setCurrentTagSuffix(requestedSuffix || CONFIG.defaultTagSuffix);
    input.value = nextSuffix;

    renderTagSuffixInput();
    renderTagGroupLabels();
    renderTagGroupOptions();
    syncExtractedTagsToCurrentSuffix();

    if (nextSuffix === previousSuffix) {
      setStatus(`Tag suffix unchanged: ${describeTagSuffix(nextSuffix)}`, 'info');
      return;
    }

    setStatus(
      nextSuffix
        ? `Tag suffix updated: ${nextSuffix}`
        : 'Tag suffix cleared.',
      'success'
    );
  }

  function createTagGroupRow(groupKey, buttonStyle) {
    const definition = getTagGroupDefinition(groupKey);
    const row = document.createElement('div');
    row.style.cssText = 'display:flex;align-items:center;gap:6px;';

    const label = createControlLabel(definition.shortLabel);
    label.setAttribute('data-ex-eagle-tag-group-label', groupKey);
    const select = document.createElement('select');
    select.id = UI.tagGroupSelectIds[groupKey];
    select.style.cssText = [
      'flex:1',
      'min-width:0',
      'padding:6px 8px',
      'border:1px solid #666',
      'border-radius:4px',
      'background:#1d1f23',
      'color:#f1f1f1'
    ].join(';');
    select.addEventListener('change', () => {
      setSelectedTagGroupId(groupKey, select.value);
      const currentDefinition = getTagGroupDefinition(groupKey);
      setStatus(`${currentDefinition.label}: ${getCurrentTagGroupLabel(groupKey)}`, 'info');
    });

    const refreshButton = document.createElement('button');
    refreshButton.id = UI.tagGroupRefreshButtonIds[groupKey];
    refreshButton.type = 'button';
    refreshButton.textContent = 'Groups';
    refreshButton.style.cssText = buttonStyle;
    refreshButton.addEventListener('click', async () => {
      await loadTagGroupOptions();
      const currentDefinition = getTagGroupDefinition(groupKey);
      setStatus(`${currentDefinition.label} list refreshed. Current: ${getCurrentTagGroupLabel(groupKey)}`, 'info');
    });

    row.appendChild(label);
    row.appendChild(select);
    row.appendChild(refreshButton);
    return row;
  }

  function buildPanel() {
    if (document.getElementById(UI.panelId)) return;

    const panel = document.createElement('div');
    panel.id = UI.panelId;
    panel.dataset.dockOpen = 'false';
    panel.style.cssText = [
      'display:flex',
      'flex-direction:column',
      'gap:8px',
      'padding:10px',
      'margin-top:10px',
      'border:1px solid #5c0d12',
      'border-radius:6px',
      'background:#2f3136',
      'box-shadow:0 2px 10px rgba(0,0,0,0.25)',
      'font-size:12px',
      'line-height:1.4',
      'min-width:180px',
      'max-width:calc(100vw - 16px)',
      'position:fixed',
      'top:16px',
      'right:16px',
      'z-index:2147483000',
      `width:${PANEL_LAYOUT.defaultWidth}px`,
      'box-sizing:border-box',
      'overflow:visible',
      'transition:transform 0.18s ease, width 0.18s ease, padding 0.18s ease, opacity 0.18s ease'
    ].join(';');

    const header = document.createElement('div');
    header.style.cssText = 'display:flex;align-items:center;gap:8px;min-width:0;';

    const title = document.createElement('div');
    title.textContent = 'Eagle Tags Picker';
    title.style.cssText = [
      'flex:1',
      'min-width:0',
      'color:#f1f1f1',
      'font-weight:700',
      'cursor:move',
      'user-select:none',
      'white-space:nowrap',
      'overflow:hidden',
      'text-overflow:ellipsis'
    ].join(';');

    const headerActions = document.createElement('div');
    headerActions.style.cssText = 'display:flex;align-items:center;gap:6px;flex-shrink:0;';

    const body = document.createElement('div');
    body.setAttribute('data-ex-eagle-panel-body', '1');
    body.style.cssText = 'display:flex;flex:1 1 auto;flex-direction:column;gap:8px;min-width:0;min-height:0;';

    const controlsSection = document.createElement('div');
    controlsSection.setAttribute('data-ex-eagle-controls-section', '1');
    controlsSection.style.cssText = 'display:flex;flex-direction:column;gap:8px;min-width:0;flex-shrink:0;';

    const toolbar = document.createElement('div');
    toolbar.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;';

    const folderRow = document.createElement('div');
    folderRow.style.cssText = 'display:flex;align-items:center;gap:6px;';
    const folderLabel = createControlLabel('Folder');

    const suffixRow = document.createElement('div');
    suffixRow.style.cssText = 'display:flex;align-items:center;gap:6px;';
    const suffixLabel = createControlLabel('Suffix');

    const buttonStyle = [
      'padding:8px 10px',
      'border:1px solid #8d8d8d',
      'border-radius:4px',
      'background:#43464e',
      'color:#f1f1f1',
      'font-size:12px',
      'font-weight:600',
      'cursor:pointer'
    ].join(';');

    const headerButtonStyle = [
      'width:24px',
      'height:24px',
      'padding:0',
      'border:1px solid #8d8d8d',
      'border-radius:4px',
      'background:#43464e',
      'color:#f1f1f1',
      'font-size:13px',
      'font-weight:700',
      'cursor:pointer',
      'line-height:1'
    ].join(';');

    const controlsToggleButton = document.createElement('button');
    controlsToggleButton.type = 'button';
    controlsToggleButton.setAttribute('data-ex-eagle-controls-toggle', '1');
    controlsToggleButton.style.cssText = headerButtonStyle;
    controlsToggleButton.addEventListener('click', () => {
      setPanelControlsCollapsed(panel, !readPanelUiState(panel).controlsCollapsed);
    });

    const minimizeButton = document.createElement('button');
    minimizeButton.type = 'button';
    minimizeButton.setAttribute('data-ex-eagle-panel-minimize', '1');
    minimizeButton.style.cssText = headerButtonStyle;
    minimizeButton.addEventListener('click', () => {
      setPanelMinimized(panel, !readPanelUiState(panel).minimized);
    });

    const dockButton = document.createElement('button');
    dockButton.type = 'button';
    dockButton.setAttribute('data-ex-eagle-panel-dock', '1');
    dockButton.style.cssText = headerButtonStyle;
    dockButton.addEventListener('click', () => {
      const uiState = readPanelUiState(panel);
      if (uiState.docked) {
        setPanelDocked(panel, false);
        return;
      }
      setPanelDocked(panel, true, getNearestDockSide(panel));
    });

    headerActions.appendChild(controlsToggleButton);
    headerActions.appendChild(minimizeButton);
    headerActions.appendChild(dockButton);
    header.appendChild(title);
    header.appendChild(headerActions);

    const refreshButton = document.createElement('button');
    refreshButton.id = UI.refreshButtonId;
    refreshButton.type = 'button';
    refreshButton.textContent = 'Refresh';
    refreshButton.style.cssText = buttonStyle;
    refreshButton.addEventListener('click', handleRefreshClick);

    const selectAllButton = document.createElement('button');
    selectAllButton.id = UI.selectAllButtonId;
    selectAllButton.type = 'button';
    selectAllButton.textContent = 'Select All';
    selectAllButton.style.cssText = buttonStyle;
    selectAllButton.addEventListener('click', () => {
      selectAllTags();
      setStatus(`Selected ${state.extractedTags.length} tags.`, 'info');
    });

    const clearButton = document.createElement('button');
    clearButton.id = UI.clearButtonId;
    clearButton.type = 'button';
    clearButton.textContent = 'Clear All';
    clearButton.style.cssText = buttonStyle;
    clearButton.addEventListener('click', () => {
      clearAllTags();
      setStatus('Cleared all selections.', 'info');
    });

    toolbar.appendChild(refreshButton);
    toolbar.appendChild(selectAllButton);
    toolbar.appendChild(clearButton);

    const folderSelect = document.createElement('select');
    folderSelect.id = UI.folderSelectId;
    folderSelect.style.cssText = [
      'flex:1',
      'min-width:0',
      'padding:6px 8px',
      'border:1px solid #666',
      'border-radius:4px',
      'background:#1d1f23',
      'color:#f1f1f1'
    ].join(';');
    folderSelect.addEventListener('change', () => {
      state.selectedFolderId = normalizeText(folderSelect.value);
      saveSelectedFolderId(state.selectedFolderId);
      const label = state.selectedFolderId
        ? state.folderOptions.find((folder) => folder.id === state.selectedFolderId)?.path || state.selectedFolderId
        : 'Root Library';
      setStatus(`Target folder: ${label}`, 'info');
    });

    const folderRefreshButton = document.createElement('button');
    folderRefreshButton.id = UI.folderRefreshButtonId;
    folderRefreshButton.type = 'button';
    folderRefreshButton.textContent = 'Folders';
    folderRefreshButton.style.cssText = buttonStyle;
    folderRefreshButton.addEventListener('click', async () => {
      await loadFolderOptions();
      const label = state.selectedFolderId
        ? state.folderOptions.find((folder) => folder.id === state.selectedFolderId)?.path || state.selectedFolderId
        : 'Root Library';
      setStatus(`Folder list refreshed. Current: ${label}`, 'info');
    });

    folderRow.appendChild(folderLabel);
    folderRow.appendChild(folderSelect);
    folderRow.appendChild(folderRefreshButton);

    const suffixInput = document.createElement('input');
    suffixInput.id = UI.tagSuffixInputId;
    suffixInput.type = 'text';
    suffixInput.spellcheck = false;
    suffixInput.style.cssText = [
      'flex:1',
      'min-width:0',
      'padding:6px 8px',
      'border:1px solid #666',
      'border-radius:4px',
      'background:#1d1f23',
      'color:#f1f1f1'
    ].join(';');
    suffixInput.addEventListener('keydown', (event) => {
      if (event.key === 'Enter') {
        event.preventDefault();
        handleTagSuffixApply();
      }
    });

    const suffixApplyButton = document.createElement('button');
    suffixApplyButton.id = UI.tagSuffixApplyButtonId;
    suffixApplyButton.type = 'button';
    suffixApplyButton.textContent = 'Set';
    suffixApplyButton.style.cssText = buttonStyle;
    suffixApplyButton.addEventListener('click', handleTagSuffixApply);

    suffixRow.appendChild(suffixLabel);
    suffixRow.appendChild(suffixInput);
    suffixRow.appendChild(suffixApplyButton);

    const cTagGroupRow = createTagGroupRow('c', buttonStyle);
    const parodyTagGroupRow = createTagGroupRow('parody', buttonStyle);
    const characterTagGroupRow = createTagGroupRow('character', buttonStyle);
    const groupTagGroupRow = createTagGroupRow('group', buttonStyle);
    const artistTagGroupRow = createTagGroupRow('artist', buttonStyle);

    const summary = document.createElement('div');
    summary.id = UI.summaryId;
    summary.textContent = 'Loading tags...';
    summary.style.cssText = 'color:#d7d7d7;flex-shrink:0;';

    const list = document.createElement('div');
    list.id = UI.listId;
    list.style.cssText = [
      'flex:1 1 auto',
      'min-height:0',
      'max-height:260px',
      'overflow:auto',
      'padding:6px',
      'border:1px solid #555',
      'border-radius:4px',
      'background:#24262b'
    ].join(';');

    const button = document.createElement('button');
    button.id = UI.buttonId;
    button.type = 'button';
    button.textContent = 'Sync Selected to Eagle';
    button.style.cssText = buttonStyle;
    button.addEventListener('click', handleSyncClick);
    button.style.flexShrink = '0';

    const status = document.createElement('div');
    status.id = UI.statusId;
    status.textContent = 'Ready';
    status.style.cssText = 'color:#d7d7d7; word-break:break-word; flex-shrink:0;';

    const dockPeek = document.createElement('div');
    dockPeek.setAttribute('data-ex-eagle-panel-peek', '1');
    dockPeek.textContent = 'Eagle Tags Picker';
    dockPeek.style.cssText = [
      'display:none',
      'position:absolute',
      'top:6px',
      'bottom:6px',
      `width:${PANEL_LAYOUT.dockPeekSize}px`,
      'align-items:center',
      'justify-content:center',
      'background:#24262b',
      'color:#d7d7d7',
      'font-size:10px',
      'font-weight:700',
      'writing-mode:vertical-rl',
      'text-orientation:mixed',
      'user-select:none',
      'pointer-events:none'
    ].join(';');

    controlsSection.appendChild(folderRow);
    controlsSection.appendChild(suffixRow);
    controlsSection.appendChild(cTagGroupRow);
    controlsSection.appendChild(parodyTagGroupRow);
    controlsSection.appendChild(characterTagGroupRow);
    controlsSection.appendChild(groupTagGroupRow);
    controlsSection.appendChild(artistTagGroupRow);
    controlsSection.appendChild(toolbar);
    body.appendChild(controlsSection);
    body.appendChild(summary);
    body.appendChild(list);
    body.appendChild(button);
    body.appendChild(status);
    panel.appendChild(header);
    panel.appendChild(body);
    panel.appendChild(dockPeek);
    document.body.appendChild(panel);

    capturePanelExpandedHeight(panel);

    const savedUiState = loadPanelUiState();
    writePanelUiState(panel, savedUiState);
    updatePanelChrome(panel);

    const savedPosition = loadPanelPosition();
    applyPanelPosition(panel, savedPosition || {
      left: Math.max(getViewportContentWidth() - panel.offsetWidth - 16, PANEL_LAYOUT.margin),
      top: 16
    });
    if (savedUiState.docked) {
      snapPanelToDockSide(panel, savedUiState.dockSide);
    }
    updatePanelChrome(panel);

    enablePanelDrag(panel, title);
    enablePanelDockHover(panel);
    window.addEventListener('resize', () => {
      handlePanelViewportChange(panel);
    });

    renderFolderOptions();
    renderTagSuffixInput();
    renderTagGroupLabels();
    loadFolderOptions().catch((error) => {
      console.warn('[Ex Gallery Tags to Eagle] Folder initialization failed:', error);
    });
    renderTagGroupOptions();
    loadTagGroupOptions().catch((error) => {
      console.warn('[Ex Gallery Tags to Eagle] Tag group initialization failed:', error);
    });
    handleRefreshClick();
  }

  function boot() {
    if (!/\/g\/\d+\/[A-Za-z0-9]+\/?$/.test(location.pathname)) return;
    buildPanel();
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot, { once: true });
  } else {
    boot();
  }
})();