" (запасной вариант — первая ссылка на предмет внутри)
function parseCollectionItems(root) {
const ids = [];
root.querySelectorAll('.collectionItem').forEach(el => {
const id = el.id?.match(/^sharedfile_(\d+)$/)?.[1]
?? idOf(el.querySelector('a[href*="filedetails/?id="]')?.getAttribute('href') ?? '');
if (id) ids.push(id);
});
return [...new Set(ids)];
}
async function fetchCollectionMods(id) {
const res = await fetch(`/sharedfiles/filedetails/?id=${id}`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
const ids = parseCollectionItems(doc);
if (!ids.length) throw new Error('в коллекции не найдено ни одного предмета');
return ids;
}
function getCollectionMods(id) {
if (colCache[id]) return Promise.resolve(colCache[id].ids);
if (!inflight.has(id)) {
inflight.set(id, fetchCollectionMods(id)
.then(ids => {
colCache[id] = { t: Date.now(), ids };
failed.delete(id);
persistCollections();
return ids;
})
.catch(err => {
failed.add(id);
console.error('[SteamWorkshopCopyId] коллекция', id, err);
throw err;
})
.finally(() => {
inflight.delete(id);
render();
}));
}
return inflight.get(id);
}
// Загружает коллекции с ограничением параллелизма, возвращает число ошибок
async function resolveCollections(ids) {
const queue = [...ids];
let errors = 0;
const worker = async () => {
while (queue.length) {
try { await getCollectionMods(queue.shift()); } catch { errors++; }
}
};
await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, queue.length) }, worker));
return errors;
}
const collectionIdsInSelection = () =>
[...selected].filter(t => t[0] === 'c').map(t => t.slice(1));
// Фоново подгружаем выбранные коллекции, чтобы счётчик показывал реальное число модов
function prefetchCollections() {
const todo = collectionIdsInSelection().filter(id => !colCache[id] && !failed.has(id) && !inflight.has(id));
if (todo.length) resolveCollections(todo);
}
// Выбор -> плоский список ID модов (порядок сохраняется, дубли убираются)
function expandSelection() {
const ids = new Set();
let collections = 0, pending = 0, errors = 0;
for (const token of selected) {
if (token[0] !== 'c') { ids.add(token); continue; }
collections++;
const id = token.slice(1);
if (colCache[id]) colCache[id].ids.forEach(m => ids.add(m));
else if (failed.has(id)) errors++;
else pending++;
}
return { ids: [...ids], collections, pending, errors };
}
// ─── Карточки ─────────────────────────────────────────────────────────────
const titleLinkOf = (el, id) =>
[...el.querySelectorAll('a[href*="filedetails/?id="]')]
.find(a => !a.querySelector('img') && idOf(a.href) === id) ?? null;
function findCard(thumb, id) {
let el = thumb.parentElement;
for (let depth = 0; el && el !== document.body && depth < 6; depth++, el = el.parentElement) {
if (titleLinkOf(el, id)) return el;
}
return null;
}
const tokenOf = card => (card.dataset.swciKind === 'collection' ? 'c' : '') + card.dataset.swciId;
function createCheck() {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'swci-check';
btn.setAttribute('role', 'checkbox');
btn.innerHTML = ICON_CHECK;
btn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
onCheckClick(btn.closest('.swci-card'), e.shiftKey);
});
return btn;
}
function createChips() {
const wrap = document.createElement('div');
wrap.className = 'swci-chips';
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'swci-id swci-copy-id';
chip.innerHTML = ICON_COPY + '';
chip.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
copyWithFlash(chip, chip.dataset.id, 'Скопировано');
});
wrap.append(chip);
return wrap;
}
function createModsChip() {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'swci-id swci-mods';
chip.title = 'Скопировать ID всех модов этой коллекции';
chip.innerHTML = ICON_LIST + 'Моды';
chip.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
copyCollectionMods(chip, chip.dataset.id);
});
return chip;
}
async function copyCollectionMods(btn, id) {
if (btn.dataset.flash === 'busy') return;
flash(btn, 'Загрузка…', 'busy', 0);
try {
const ids = await getCollectionMods(id);
await copyToClipboard(ids.join(FORMATS[format].sep));
flash(btn, `Скопировано: ${ids.length}`, 'done');
} catch (err) {
flash(btn, 'Ошибка', 'fail');
console.error('[SteamWorkshopCopyId]', err);
}
}
// Чипы карточки зависят от её типа: у коллекции дополнительно есть кнопка «Моды»
function syncChips(chips, kind, id) {
const idChip = chips.querySelector('.swci-copy-id');
idChip.dataset.id = id;
idChip.title = kind === 'collection' ? 'Скопировать ID коллекции' : 'Скопировать ID предмета';
let mods = chips.querySelector('.swci-mods');
if (kind === 'collection') {
if (!mods) chips.append(mods = createModsChip());
mods.dataset.id = id;
} else {
mods?.remove();
}
}
function prepareCard(card, id, kind) {
card.classList.add('swci-card');
card.dataset.swciId = id; // React может переиспользовать узел для другого предмета
card.dataset.swciKind = kind;
let check = card.querySelector(':scope > .swci-check');
if (!check) card.append(check = createCheck());
check.title = kind === 'collection'
? 'Выбрать коллекцию — в список попадут все её моды (Shift — диапазон)'
: 'Выбрать (Shift — выбрать диапазон)';
check.setAttribute('aria-label', kind === 'collection' ? 'Выбрать коллекцию' : 'Выбрать предмет');
return check;
}
function inject() {
if (onDetailsPage()) return injectCollectionPage();
const kind = onCollectionsList() ? 'collection' : 'mod';
document.querySelectorAll('a[href*="/sharedfiles/filedetails/?id="]').forEach(thumb => {
if (!thumb.querySelector('img')) return; // карточку находим по ссылке-картинке
const id = idOf(thumb.href);
const card = id && findCard(thumb, id);
if (!card) return; // например, баннер без превью
prepareCard(card, id, kind);
let chips = card.querySelector('.swci-chips');
if (!chips) {
chips = createChips();
const titleBlock = titleLinkOf(card, id)?.parentElement;
const anchor = titleBlock?.nextElementSibling || titleBlock; // под строкой автора
if (anchor) anchor.after(chips);
else card.append(chips);
}
syncChips(chips, kind, id);
});
}
// Страница коллекции (старая вёрстка): моды лежат в блоках .collectionItem
function injectCollectionPage() {
document.querySelectorAll('.collectionItem').forEach(card => {
const id = card.id?.match(/^sharedfile_(\d+)$/)?.[1]
?? idOf(card.querySelector('a[href*="filedetails/?id="]')?.getAttribute('href') ?? '');
if (!id) return;
prepareCard(card, id, 'mod');
let chips = card.querySelector('.swci-chips');
if (!chips) {
chips = createChips();
(card.querySelector('.collectionItemDetails') ?? card).append(chips);
}
syncChips(chips, 'mod', id);
});
}
// ─── Выбор ────────────────────────────────────────────────────────────────
const allCards = () => [...document.querySelectorAll('.swci-card')];
function setSelected(token, on) {
if (on) selected.add(token); else selected.delete(token);
}
function onCheckClick(card, shift) {
const cards = allCards();
const index = cards.indexOf(card);
const on = !selected.has(tokenOf(card));
if (shift && lastIndex >= 0 && lastIndex < cards.length) {
const [from, to] = index < lastIndex ? [index, lastIndex] : [lastIndex, index];
for (let i = from; i <= to; i++) setSelected(tokenOf(cards[i]), on);
} else {
setSelected(tokenOf(card), on);
}
lastIndex = index;
commit();
}
function commit() {
persist();
prefetchCollections();
render();
}
async function copySelection(btn) {
if (btn.dataset.flash === 'busy') return;
const missing = collectionIdsInSelection().filter(id => !colCache[id]);
let loadErrors = 0;
if (missing.length) {
flash(btn, `Загрузка коллекций: ${missing.length}…`, 'busy', 0);
loadErrors = await resolveCollections(missing);
}
const { ids } = expandSelection();
if (!ids.length) {
flash(btn, 'Ошибка', 'fail');
return;
}
try {
await copyToClipboard(ids.join(FORMATS[format].sep));
flash(btn, loadErrors ? `Скопировано: ${ids.length}, ошибок: ${loadErrors}` : `Скопировано: ${ids.length}`,
loadErrors ? 'fail' : 'done');
} catch (err) {
flash(btn, 'Ошибка', 'fail');
console.error('[SteamWorkshopCopyId]', err);
}
}
// ─── Панель ───────────────────────────────────────────────────────────────
const panel = document.createElement('div');
panel.className = 'swci-panel';
panel.setAttribute('role', 'region');
panel.setAttribute('aria-label', 'Копирование ID мастерской');
panel.innerHTML = `
Выбрано: 0
${Object.entries(FORMATS).map(([key, f]) => ``).join('')}
`;
const ref = name => panel.querySelector(`[data-ref="${name}"]`);
const act = name => panel.querySelector(`[data-act="${name}"]`);
panel.addEventListener('click', e => {
const fmtBtn = e.target.closest('[data-fmt]');
if (fmtBtn) {
format = fmtBtn.dataset.fmt;
commit();
return;
}
const btn = e.target.closest('[data-act]');
if (!btn || btn.disabled) return;
switch (btn.dataset.act) {
case 'copy':
copySelection(btn);
break;
case 'clear':
selected.clear();
lastIndex = -1;
commit();
break;
case 'page': {
const tokens = allCards().map(tokenOf);
const everySelected = tokens.every(t => selected.has(t));
tokens.forEach(t => setSelected(t, !everySelected));
commit();
break;
}
case 'copy-this':
copyWithFlash(btn, currentItemId(), 'Скопировано');
break;
case 'copy-mods': {
const ids = allCards().map(c => c.dataset.swciId);
copyWithFlash(btn, ids.join(FORMATS[format].sep), `Скопировано: ${ids.length}`);
break;
}
case 'toggle-this': {
const id = currentItemId();
setSelected(id, !selected.has(id));
commit();
break;
}
}
});
function render() {
document.body.classList.toggle('swci-has-selection', selected.size > 0);
const cards = allCards();
cards.forEach(card => {
const id = card.dataset.swciId;
const on = selected.has(tokenOf(card));
card.classList.toggle('swci-selected', on);
const check = card.querySelector(':scope > .swci-check');
if (check) {
check.classList.toggle('swci-on', on);
check.setAttribute('aria-checked', String(on));
}
const chip = card.querySelector('.swci-copy-id');
if (chip) setLabel(chip, 'ID ' + id);
const mods = card.querySelector('.swci-mods');
if (mods) setLabel(mods, colCache[id] ? `Моды (${colCache[id].ids.length})` : 'Моды');
});
// Контекст страницы предмета / коллекции
const current = currentItemId();
const inCollection = !!current && cards.length > 0;
ref('ctx').hidden = !current;
ref('ctx-div').hidden = !current;
if (current) {
const copyThis = act('copy-this');
copyThis.title = inCollection ? 'Скопировать ID коллекции' : 'Скопировать ID этого предмета';
setLabel(copyThis, (inCollection ? 'Коллекция ' : 'ID ') + current);
const toggle = act('toggle-this');
toggle.hidden = inCollection;
if (!inCollection) setLabel(toggle, selected.has(current) ? 'Убрать из списка' : 'В список');
const mods = act('copy-mods');
mods.hidden = !inCollection;
if (inCollection) setLabel(mods, `Моды коллекции (${cards.length})`);
}
// Счётчик: уникальные моды + сводка по выбранным коллекциям
const { ids, collections, pending, errors } = expandSelection();
ref('count').textContent = ids.length;
const extra = ref('extra');
extra.hidden = collections === 0;
if (collections) {
const parts = [`коллекций: ${collections}`];
if (pending) parts.push('загрузка…');
if (errors) parts.push(`ошибок: ${errors}`);
const text = `(${parts.join(', ')})`;
if (extra.textContent !== text) extra.textContent = text;
extra.classList.toggle('swci-warn', errors > 0);
}
const pageBtn = act('page');
const everySelected = cards.length > 0 && cards.every(c => selected.has(tokenOf(c)));
pageBtn.hidden = !!current && !inCollection;
pageBtn.disabled = cards.length === 0;
setLabel(pageBtn, everySelected ? 'Снять со страницы' : 'Все на странице');
act('clear').disabled = selected.size === 0;
const copyBtn = act('copy');
copyBtn.disabled = selected.size === 0;
setLabel(copyBtn, ids.length ? `Копировать ID (${ids.length})` : 'Копировать ID');
panel.querySelectorAll('[data-fmt]').forEach(b =>
b.classList.toggle('swci-active', b.dataset.fmt === format));
}
// ─── Динамическая подгрузка карточек ──────────────────────────────────────
let scheduled = false;
new MutationObserver(mutations => {
// Собственные изменения (подписи кнопок, вставка контролов) игнорируем
const external = mutations.some(m => {
const el = m.target.nodeType === 1 ? m.target : m.target.parentElement;
return !el?.closest('.swci-panel, .swci-chips, .swci-id, .swci-check');
});
if (!external || scheduled) return;
scheduled = true;
requestAnimationFrame(() => { scheduled = false; inject(); render(); });
}).observe(document.body, { childList: true, subtree: true });
// Выбор в соседней вкладке
window.addEventListener('storage', e => {
if (e.key !== STORE_KEY && e.key !== FORMAT_KEY) return;
selected = new Set(loadSelection());
format = loadFormat();
prefetchCollections();
render();
});
document.body.appendChild(panel);
inject();
prefetchCollections();
render();
})();