Sleazy Fork is available in English.
Extract files and tags from ExHentai/E-Hentai gallery pages and sync them to Eagle as bookmark items.
// ==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();
}
})();