ExGallery Url to Eagle

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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