// ==UserScript==
// @name ed2k Manager
// @namespace Userscript
// @version 1.2.1
// @description Reveal ed2k links on any page with robust ed2k decoding and advanced tome/integrale extraction.
// @author L@nnes
// @match *://*/*
// @grant GM_setClipboard
// @grant GM_addStyle
// @run-at document-idle
// @updateURL https://raw.githubusercontent.com/L-at-nnes/ed2k-Manager/main/ed2k-manager.js
// @downloadURL https://raw.githubusercontent.com/L-at-nnes/ed2k-Manager/main/ed2k-manager.js
// ==/UserScript==
(function () {
'use strict';
// Only run in the top-level window, not in iframes (avoids duplicate buttons in forum editors)
if (window.self !== window.top) return;
// === Configuration ===
const STORAGE_KEY = 'ed2k_revealer_prefs_v1';
// === Utilities ===
function savePrefs(p) { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); }
function loadPrefs() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch (e) { return {}; } }
// keep only button visibility in prefs; theme fixed to ocean
const prefs = Object.assign({ showButton: true, btnPos: 'bottom-right' }, loadPrefs());
// ed2k detection: capture full candidates, then normalize/parse
const ED2K_PREFIX = 'ed2k://';
const ED2K_BASE_REGEX = /^ed2k:\/\/\|file\|([^|]+)\|([0-9]+)\|([a-fA-F0-9]{32})\|/i;
const ED2K_CANDIDATE_REGEX = /ed2k:\/\/[^\s<>"']+/gi;
function decodeFileName(raw) {
try {
// some names are URL-encoded
let s = raw.replace(/\+/g, ' ');
s = decodeURIComponent(s);
s = s.replace(/\s+/g, ' ').trim();
return s;
} catch (e) { return raw; }
}
function safeDecodeURIComponent(value) {
try { return decodeURIComponent(value); } catch (e) { return value; }
}
function decodeEd2kLink(rawLink) {
const trimmed = String(rawLink || '').trim();
if (!trimmed || trimmed.indexOf('%') === -1) return trimmed;
// Many sites encode pipes as %7C; decode them first, then try full decode.
const pipeDecoded = trimmed.replace(/%7C/gi, '|');
return safeDecodeURIComponent(pipeDecoded);
}
function stripTrailingPunctuation(candidate) {
// Remove common trailing punctuation that may follow a link in plain text.
return String(candidate || '').replace(/[)\].,!?:;]+$/g, '');
}
function ensureTrailingSlash(link) {
if (!link) return link;
return link.endsWith('/') ? link : `${link}/`;
}
function buildItemFromLink(rawLink) {
const cleaned = stripTrailingPunctuation(rawLink);
if (!cleaned || cleaned.toLowerCase().indexOf(ED2K_PREFIX) !== 0) return null;
// Only decode pipe characters (%7C) for regex matching; keep the rest of
// the URL-encoding intact so ed2k clients can parse the link correctly
// (fully decoding introduced raw apostrophes/spaces that broke HTML
// attributes and made links unparseable by eMule).
const pipeDecoded = cleaned.replace(/%7C/gi, '|');
const withSlash = ensureTrailingSlash(pipeDecoded);
const normalizedLink = withSlash.replace(/^ed2k:\/\//i, ED2K_PREFIX);
const match = normalizedLink.match(ED2K_BASE_REGEX);
if (!match) return null;
const name = decodeFileName(match[1]);
const tomeInfo = extractTomeNumber(name);
return {
name,
link: normalizedLink,
size: match[2],
hash: String(match[3] || '').toLowerCase(),
tomeDisplay: tomeInfo.display,
tomeSortValue: tomeInfo.sortValue,
};
}
function findEd2kLinks() {
const found = new Map(); // key by normalized link
function addCandidate(rawLink) {
const item = buildItemFromLink(rawLink);
if (!item) return;
if (!found.has(item.link)) found.set(item.link, item);
}
// 1) anchors with ed2k hrefs (including percent-encoded pipes)
document.querySelectorAll('a[href]').forEach(a => {
// Exclude our own UI elements
if (a.closest('.ed2k-rev-btn') || a.closest('.ed2k-rev-modal')) return;
const href = a.getAttribute('href') || '';
if (href.toLowerCase().indexOf(ED2K_PREFIX) !== 0) return;
addCandidate(href);
});
// 2) plain text nodes containing ed2k links
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
const tag = parent.tagName ? parent.tagName.toLowerCase() : '';
if (tag === 'script' || tag === 'style' || tag === 'textarea' || tag === 'noscript' || tag === 'input') {
return NodeFilter.FILTER_REJECT;
}
// Exclude our own UI elements (button, modal, badge)
if (parent.closest('.ed2k-rev-btn') || parent.closest('.ed2k-rev-modal')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
let node;
while ((node = walker.nextNode())) {
const text = node.nodeValue;
if (!text || text.toLowerCase().indexOf(ED2K_PREFIX) === -1) continue;
const matches = text.match(ED2K_CANDIDATE_REGEX);
if (!matches) continue;
matches.forEach(addCandidate);
}
return Array.from(found.values());
}
// === UI creation ===
const CSS = `
/* Strong, self-contained styles to avoid being overridden by page CSS */
.ed2k-rev-btn{position:fixed;z-index:9999999;right:18px;bottom:18px;width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#5fd6f6,#60a5fa);box-shadow:0 8px 30px rgba(2,6,23,0.6);cursor:pointer;color:#041423;border:none}
.ed2k-rev-btn svg{filter:drop-shadow(0 1px 0 rgba(255,255,255,0.06));}
.ed2k-logo{font-weight:800;color:#022;letter-spacing:0.6px;font-family:Inter,Segoe UI,Roboto,Arial;font-size:16px;background:linear-gradient(90deg,#e6fbff,#7dd3fc);-webkit-background-clip:text;background-clip:text;color:transparent;text-transform:lowercase}
.ed2k-rev-btn .ed2k-logo-wrap{display:flex;align-items:center;justify-content:center;width:100%;height:100%;border-radius:50%;background:radial-gradient(circle at 30% 30%, rgba(255,255,255,0.12), transparent 40%);}
.ed2k-rev-modal{position:fixed;right:24px;bottom:88px;z-index:9999998;width:880px;max-width:calc(100% - 48px);max-height:80vh;background:#07101a;border-radius:12px;padding:14px;box-shadow:0 20px 60px rgba(2,6,23,0.8);overflow:hidden;display:flex;flex-direction:column;color:#e6eef8;font-family:system-ui,Segoe UI,Roboto,Arial}
.ed2k-rev-header{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid rgba(255,255,255,0.04);}
.ed2k-rev-title{font-weight:700;color:#7dd3fc;font-size:14px}
.ed2k-rev-selection{font-size:12px;color:#bfefff;opacity:0.95;padding:4px 8px;border:1px solid rgba(255,255,255,0.06);border-radius:999px;background:rgba(255,255,255,0.04)}
.ed2k-rev-toolbar{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.ed2k-rev-search{padding:8px 10px;border-radius:10px;border:1px solid rgba(255,255,255,0.04);background:rgba(255,255,255,0.02);color:#cfe8f6;min-width:260px}
.ed2k-size-input{padding:6px 8px;border-radius:8px;border:1px solid rgba(255,255,255,0.04);background:rgba(255,255,255,0.02);color:#cfe8f6;width:110px}
.ed2k-size-row{display:flex;gap:6px;align-items:center}
.ed2k-btn{min-width:88px;text-align:center}
.ed2k-rev-list{overflow:auto;padding:8px;flex:1;background:transparent}
table.ed2k-table{width:100%;border-collapse:collapse;font-size:13px;color:#cfe8f6}
table.ed2k-table th, table.ed2k-table td{padding:10px 8px;border-bottom:1px dashed rgba(255,255,255,0.03);}
table.ed2k-table th{color:#9aa4b2;text-align:left;font-size:12px}
.ed2k-row-name{color:#e6eef8;font-weight:600}
.ed2k-controls{display:flex;gap:8px}
.ed2k-btn{padding:6px 10px;border-radius:8px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.04);color:#cfe8f6;cursor:pointer}
.ed2k-btn.primary{background:linear-gradient(90deg,#5fd6f6,#60a5fa);color:#022;border:none}
.ed2k-empty{padding:28px;text-align:center;color:#9aa4b2}
a.ed2k-link{color:#7dd3fc;text-decoration:none}
/* Badge on the floating button */
.ed2k-badge{position:absolute;top:-6px;right:-6px;min-width:18px;height:18px;padding:0 6px;border-radius:999px;background:#021827;color:#9ef;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;box-shadow:0 6px 14px rgba(2,6,23,0.5)}
/* Ocean theme only (fixed) */
.ed2k-rev-modal{background:linear-gradient(180deg,#062a3a,#023f4f);color:#e6fbff}
.ed2k-rev-title{color:#8fe7ff}
table.ed2k-table th{color:#b6e6f4}
.ed2k-btn{background:rgba(255,255,255,0.03);color:#e6fbff;border:1px solid rgba(255,255,255,0.04)}
.ed2k-btn.primary{background:linear-gradient(90deg,#2ad0e6,#60a5fa);color:#012}
.ed2k-rev-search{background:rgba(255,255,255,0.02);color:#e6fbff;border:1px solid rgba(255,255,255,0.04)}
/* Credit / footer link: red by default, violet on hover. Use !important to avoid page CSS overrides.
SVG fill is forced too so the icon follows the color. */
.ed2k-credit{display:inline-flex;align-items:center;gap:8px;color:#ff4d4d !important;text-decoration:none;font-size:12px}
.ed2k-credit svg{width:16px;height:16px;fill:currentColor !important;opacity:0.95}
.ed2k-credit:hover, .ed2k-credit:focus{color:#8b5cf6 !important}
.ed2k-credit:hover svg, .ed2k-credit:focus svg{fill:currentColor !important}
`;
GM_addStyle(CSS);
// create button
const btn = document.createElement('button');
btn.className = 'ed2k-rev-btn';
btn.title = 'Afficher les liens ed2k';
// stylized ed2k logo inside the button
btn.innerHTML = '
ed2k
';
// helpers to apply position and size from prefs
function applyButtonPosition() {
const pos = prefs.btnPos || 'bottom-right';
// reset
btn.style.top = '';
btn.style.bottom = '';
btn.style.left = '';
btn.style.right = '';
switch(pos){
case 'top-left': btn.style.top = '18px'; btn.style.left = '18px'; break;
case 'top-right': btn.style.top = '18px'; btn.style.right = '18px'; break;
case 'bottom-left': btn.style.bottom = '18px'; btn.style.left = '18px'; break;
default: btn.style.bottom = '18px'; btn.style.right = '18px'; break;
}
}
function applyButtonSize(){
const size = prefs.btnSize || 'normal';
let dim = 56;
if (size === 'small') dim = 40; else if (size === 'large') dim = 72;
btn.style.width = dim + 'px'; btn.style.height = dim + 'px';
// adjust svg inside
const svg = btn.querySelector('svg'); if (svg) { svg.setAttribute('width', Math.round(dim*0.4)); svg.setAttribute('height', Math.round(dim*0.4)); }
}
// Don't append button yet - wait to see if there are links
// apply initial prefs
try { applyButtonPosition(); applyButtonSize(); } catch(e){}
// modal
let modal = null;
let buttonAppended = false;
function buildModal(items) {
if (modal) modal.remove();
modal = document.createElement('div');
modal.className = 'ed2k-rev-modal';
const header = document.createElement('div'); header.className = 'ed2k-rev-header';
const title = document.createElement('div'); title.className = 'ed2k-rev-title'; title.textContent = `ed2k — ${items.length} trouvé(s)`;
header.appendChild(title);
const selectionInfo = document.createElement('div'); selectionInfo.className = 'ed2k-rev-selection'; selectionInfo.textContent = 'Sélection: 0';
header.appendChild(selectionInfo);
const toolbar = document.createElement('div'); toolbar.className = 'ed2k-rev-toolbar';
const search = document.createElement('input'); search.className = 'ed2k-rev-search'; search.placeholder = 'Filtrer par nom (ou /regex/flags) ...';
const selectAllBtn = document.createElement('button'); selectAllBtn.className = 'ed2k-btn'; selectAllBtn.textContent = 'Tout sélectionner';
const deselectAllBtn = document.createElement('button'); deselectAllBtn.className = 'ed2k-btn'; deselectAllBtn.textContent = 'Tout déselectionner';
const copyBtn = document.createElement('button'); copyBtn.className = 'ed2k-btn primary'; copyBtn.textContent = 'Copier sélection';
const copyAllBtn = document.createElement('button'); copyAllBtn.className = 'ed2k-btn'; copyAllBtn.textContent = 'Copier tout';
const exportBtn = document.createElement('button'); exportBtn.className = 'ed2k-btn'; exportBtn.textContent = 'Exporter CSV';
const exportCollectionBtn = document.createElement('button'); exportCollectionBtn.className = 'ed2k-btn'; exportCollectionBtn.textContent = 'Exporter .emulecollection';
// size filters UI
const sizeRow = document.createElement('div'); sizeRow.className = 'ed2k-size-row';
const minInput = document.createElement('input'); minInput.className = 'ed2k-size-input'; minInput.placeholder = 'Min (ex: 10MB)';
const maxInput = document.createElement('input'); maxInput.className = 'ed2k-size-input'; maxInput.placeholder = 'Max (ex: 2GB)';
const clearSizeBtn = document.createElement('button'); clearSizeBtn.className = 'ed2k-btn'; clearSizeBtn.textContent = 'Effacer taille';
sizeRow.appendChild(minInput); sizeRow.appendChild(maxInput); sizeRow.appendChild(clearSizeBtn);
toolbar.appendChild(search);
// put size filters next to search
toolbar.appendChild(sizeRow);
toolbar.appendChild(selectAllBtn); toolbar.appendChild(deselectAllBtn); toolbar.appendChild(copyBtn); toolbar.appendChild(copyAllBtn); toolbar.appendChild(exportBtn); toolbar.appendChild(exportCollectionBtn);
header.appendChild(toolbar);
const list = document.createElement('div'); list.className = 'ed2k-rev-list';
const table = document.createElement('table'); table.className = 'ed2k-table';
const thead = document.createElement('thead'); thead.innerHTML = ' | Tome | Nom | Taille | Lien |
';
const tbody = document.createElement('tbody');
// Selection state persists across re-renders and enables Shift+click range selection.
const selectedLinks = new Set();
let lastClickedLink = null;
function getVisibleCheckboxes() {
return Array.from(modal.querySelectorAll('tbody input.ed2k-row-cb'));
}
function getSelectedItems() {
return items.filter(it => selectedLinks.has(it.link));
}
function setCheckedAndTrack(cb, checked) {
cb.checked = checked;
const link = cb.dataset.link;
if (!link) return;
if (checked) {
selectedLinks.add(link);
} else {
selectedLinks.delete(link);
}
}
function syncSelectedFromVisible() {
getVisibleCheckboxes().forEach(cb => {
const link = cb.dataset.link;
if (!link) return;
if (cb.checked) {
selectedLinks.add(link);
} else {
selectedLinks.delete(link);
}
});
}
function setAllVisible(checked) {
getVisibleCheckboxes().forEach(cb => setCheckedAndTrack(cb, checked));
updateMasterCheckbox();
}
function getVisibleIndexByLink(link) {
if (!link) return -1;
const boxes = getVisibleCheckboxes();
return boxes.findIndex(cb => cb.dataset.link === link);
}
function applyRangeSelection(startIdx, endIdx, checked) {
const boxes = getVisibleCheckboxes();
if (!boxes.length) return;
const start = Math.max(0, Math.min(startIdx, endIdx));
const end = Math.min(boxes.length - 1, Math.max(startIdx, endIdx));
for (let i = start; i <= end; i += 1) {
setCheckedAndTrack(boxes[i], checked);
}
}
function handleCheckboxClick(cb, evt) {
const boxes = getVisibleCheckboxes();
const targetIdx = boxes.indexOf(cb);
const targetChecked = !!cb.checked;
if (evt && evt.shiftKey && lastClickedLink) {
const anchorIdx = getVisibleIndexByLink(lastClickedLink);
if (anchorIdx !== -1 && targetIdx !== -1) {
applyRangeSelection(anchorIdx, targetIdx, targetChecked);
updateMasterCheckbox();
return;
}
}
setCheckedAndTrack(cb, targetChecked);
updateMasterCheckbox();
}
function renderRows(query) {
// Persist any manual checkbox changes before rebuilding the table.
syncSelectedFromVisible();
tbody.innerHTML = '';
const frag = document.createDocumentFragment();
const filterFn = makeFilterFromQuery(query || '');
// apply size filters as well
const minBytes = parseSize(minInput.value);
const maxBytes = parseSize(maxInput.value);
const filtered = items.filter(it => {
if (!filterFn(it)) return false;
const sizeNum = parseInt(it.size||0,10) || 0;
if (minBytes != null && sizeNum < minBytes) return false;
if (maxBytes != null && sizeNum > maxBytes) return false;
return true;
}).slice();
filtered.sort((a, b) => {
if (sortState.col === 'tome') {
const aMissing = !Number.isFinite(a.tomeSortValue);
const bMissing = !Number.isFinite(b.tomeSortValue);
// Always keep items without tome information at the end.
if (aMissing && !bMissing) return 1;
if (bMissing && !aMissing) return -1;
if (!aMissing && !bMissing && a.tomeSortValue !== b.tomeSortValue) {
return sortState.dir * (a.tomeSortValue - b.tomeSortValue);
}
return a.name.localeCompare(b.name);
}
if (sortState.col === 'name') return sortState.dir * a.name.localeCompare(b.name);
if (sortState.col === 'size') {
const aSize = parseInt(a.size || 0, 10) || 0;
const bSize = parseInt(b.size || 0, 10) || 0;
if (aSize !== bSize) return sortState.dir * (aSize - bSize);
return a.name.localeCompare(b.name);
}
return 0;
});
// update title count dynamically
try { title.textContent = `ed2k — ${filtered.length} trouvé(s)`; } catch(e){}
filtered.forEach((it, idx) => {
const tr = document.createElement('tr');
const cbTd = document.createElement('td');
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.dataset.link = it.link; cb.className = 'ed2k-row-cb';
cb.checked = selectedLinks.has(it.link);
cb.addEventListener('click', (evt) => {
handleCheckboxClick(cb, evt);
lastClickedLink = cb.dataset.link || null;
});
cbTd.appendChild(cb);
const tomeTd = document.createElement('td');
tomeTd.style.textAlign = 'center';
tomeTd.innerHTML = `${escapeHtml(it.tomeDisplay || '')}
`;
const nameTd = document.createElement('td');
const nameDiv = document.createElement('div');
nameDiv.className = 'ed2k-row-name';
nameDiv.textContent = it.name;
nameDiv.style.cursor = 'pointer';
nameDiv.title = 'Cliquer pour copier le lien';
nameDiv.addEventListener('click', async (evt) => {
cb.checked = !cb.checked;
handleCheckboxClick(cb, evt);
lastClickedLink = cb.dataset.link || null;
const copied = await copyTextToClipboard(it.link);
if (!copied) return;
const origBg = tr.style.background;
tr.style.background = 'rgba(95, 214, 246, 0.15)';
setTimeout(() => { tr.style.background = origBg; }, 300);
});
nameTd.appendChild(nameDiv);
const sizeTd = document.createElement('td'); sizeTd.style.textAlign = 'right';
// show size in MB (fixed) and keep raw bytes in tooltip
const _bytes_val = parseInt(it.size || 0, 10) || 0;
const _mb_display = _bytes_val ? ( (_bytes_val / (1024*1024)).toFixed(2) + ' MB') : '';
sizeTd.innerHTML = `${_mb_display}
`;
const linkTd = document.createElement('td'); linkTd.innerHTML = `${escapeHtml(shorten(it.link))}`;
tr.appendChild(cbTd); tr.appendChild(tomeTd); tr.appendChild(nameTd); tr.appendChild(sizeTd); tr.appendChild(linkTd);
frag.appendChild(tr);
});
tbody.appendChild(frag);
updateMasterCheckbox();
}
table.appendChild(thead); table.appendChild(tbody);
list.appendChild(table);
const footer = document.createElement('div'); footer.style.padding = '8px'; footer.style.display = 'flex'; footer.style.justifyContent = 'space-between'; footer.style.alignItems = 'center';
// left: credit link (make it clearly visible)
const credit = document.createElement('a');
credit.className = 'ed2k-credit';
credit.href = 'https://github.com/L-at-nnes';
credit.target = '_blank';
credit.rel = 'noopener noreferrer';
credit.title = 'https://github.com/L-at-nnes';
credit.style.fontWeight = '600';
credit.style.opacity = '0.95';
credit.style.display = 'inline-flex';
credit.style.alignItems = 'center';
credit.style.gap = '8px';
credit.innerHTML = `
L@nnes
`;
// right: close button (ensure it closes modal)
const closeBtn = document.createElement('button'); closeBtn.className = 'ed2k-btn'; closeBtn.textContent = 'Fermer';
closeBtn.addEventListener('click', () => { try { modal.remove(); modal = null; } catch (e) {} });
footer.appendChild(credit);
footer.appendChild(closeBtn);
modal.appendChild(header); modal.appendChild(list); modal.appendChild(footer);
document.body.appendChild(modal);
// helpers
function updateSelectionInfo() {
const count = getSelectedItems().length;
selectionInfo.textContent = `Sélection: ${count}`;
}
function updateMasterCheckbox() {
const master = modal.querySelector('#ed2k-master');
const boxes = getVisibleCheckboxes();
updateSelectionInfo();
if (!boxes.length) { master.checked = false; master.indeterminate = false; return; }
const checked = boxes.filter(x => x.checked).length;
master.checked = checked === boxes.length;
master.indeterminate = checked > 0 && checked < boxes.length;
}
// listeners
modal.querySelector('#ed2k-master').addEventListener('change', (e) => {
const v = e.target.checked;
if (!v) selectedLinks.clear();
setAllVisible(v);
});
modal.addEventListener('change', (e) => {
if (e.target.classList && e.target.classList.contains('ed2k-row-cb')) {
setCheckedAndTrack(e.target, e.target.checked);
updateMasterCheckbox();
}
});
selectAllBtn.addEventListener('click', () => setAllVisible(true) );
deselectAllBtn.addEventListener('click', () => {
selectedLinks.clear();
setAllVisible(false);
});
copyBtn.addEventListener('click', async () => {
const selectedItems = getSelectedItems();
if (!selectedItems.length) { flashButton(copyBtn, 'Aucune sélection'); return; }
const links = selectedItems.map(it => it.link).join('\n');
const copied = await copyTextToClipboard(links);
if (!copied) { flashButton(copyBtn, 'Erreur'); return; }
flashButton(copyBtn, 'Copié!');
// close modal after copying selection
setTimeout(() => { try { modal.remove(); modal = null; } catch(e){} }, 300);
});
// copy all links (all items, regardless of checkbox)
copyAllBtn.addEventListener('click', async () => {
const links = items.map(it => it.link).join('\n');
if (!links) { flashButton(copyAllBtn, 'Aucun lien'); return; }
const copied = await copyTextToClipboard(links);
if (!copied) { flashButton(copyAllBtn, 'Erreur'); return; }
flashButton(copyAllBtn, 'Copié tout!');
setTimeout(() => { try { modal.remove(); modal = null; } catch(e){} }, 300);
});
// export CSV
exportBtn.addEventListener('click', () => {
try {
const selectedItems = getSelectedItems();
const exportItems = selectedItems.length ? selectedItems : items.slice();
if (!exportItems.length) { flashButton(exportBtn, 'Aucun lien'); return; }
const header = ['name','size','link'];
const rows = exportItems.map(it => ["\""+String(it.name).replace(/"/g,'""')+"\"", it.size, it.link].join(','));
const csv = [header.join(','), ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'ed2k-links.csv'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
flashButton(exportBtn, 'Exporté');
} catch (e) { flashButton(exportBtn, 'Erreur'); }
});
// export .emulecollection (binary) — exports selection if any, otherwise all
exportCollectionBtn.addEventListener('click', () => {
try {
const selectedItems = getSelectedItems();
const exportItems = selectedItems.length ? selectedItems : items.slice();
const limitedItems = exportItems.slice(0, 1024);
const collectionBytes = buildEmuleCollection(limitedItems);
if (!collectionBytes) {
flashButton(exportCollectionBtn, 'Aucun lien valide');
return;
}
const blob = new Blob([collectionBytes], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ed2k-links.emulecollection';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
flashButton(exportCollectionBtn, 'Exporté');
} catch (e) {
flashButton(exportCollectionBtn, 'Erreur');
}
});
function buildEmuleCollection(sourceItems) {
if (!sourceItems || !sourceItems.length) return null;
const entries = [];
sourceItems.forEach(it => {
const sizeNum = parseInt(it.size || 0, 10) || 0;
const hashBytes = hexToBytes16(it.hash);
if (!it.name || !sizeNum || !hashBytes) return;
const rootHashMatch = String(it.link || '').match(/\|h=([^|]+)\|/i);
const rootHash = rootHashMatch ? rootHashMatch[1] : '';
entries.push({
name: it.name,
size: sizeNum,
hashBytes,
rootHash,
});
});
if (!entries.length) return null;
const bytes = [];
const pushByte = (v) => { bytes.push(v & 0xFF); };
const pushBytes = (arr) => { for (let i = 0; i < arr.length; i += 1) pushByte(arr[i]); };
const writeUint16LE = (v) => { pushByte(v); pushByte(v >>> 8); };
const writeUint32LE = (v) => {
const n = v >>> 0;
pushByte(n);
pushByte(n >>> 8);
pushByte(n >>> 16);
pushByte(n >>> 24);
};
const writeUint64LE = (v) => {
let big = BigInt(v);
if (big < 0) big = 0n;
for (let i = 0n; i < 8n; i += 1n) {
pushByte(Number((big >> (8n * i)) & 0xFFn));
}
};
const encoder = new TextEncoder();
const writeString = (str) => {
const data = encoder.encode(String(str || ''));
const len = Math.min(data.length, 0xFFFF);
writeUint16LE(len);
pushBytes(data.slice(0, len));
};
// Header
writeUint32LE(0x02); // version
writeUint32LE(0x00); // header tag count
writeUint32LE(entries.length);
// File entries
entries.forEach(entry => {
const sizeBig = BigInt(entry.size);
const use64 = sizeBig > 0xFFFFFFFFn;
const tagCount = entry.rootHash ? 4 : 3;
writeUint32LE(tagCount);
// FT_FILEHASH (0x28) — TAGTYPE_HASH (0x81)
pushByte(0x81);
pushByte(0x28);
pushBytes(entry.hashBytes);
// FT_FILESIZE (0x02) — uint32 (0x83) or uint64 (0x8b)
pushByte(use64 ? 0x8B : 0x83);
pushByte(0x02);
if (use64) {
writeUint64LE(sizeBig);
} else {
writeUint32LE(Number(sizeBig));
}
// FT_FILENAME (0x01) — TAGTYPE_STRING (0x82)
pushByte(0x82);
pushByte(0x01);
writeString(entry.name);
// FT_AICH_FILEHASH (0x27) — TAGTYPE_STRING (0x82)
if (entry.rootHash) {
pushByte(0x82);
pushByte(0x27);
writeString(entry.rootHash);
}
});
return new Uint8Array(bytes);
}
function hexToBytes16(hex) {
const norm = String(hex || '').toLowerCase().replace(/[^0-9a-f]/g, '');
if (norm.length !== 32) return null;
const out = new Uint8Array(16);
for (let i = 0; i < 16; i += 1) {
const byte = parseInt(norm.slice(i * 2, (i * 2) + 2), 16);
if (Number.isNaN(byte)) return null;
out[i] = byte;
}
return out;
}
// enhanced search: support regex when value is like /pattern/flags
function makeFilterFromQuery(q){
if (!q) return () => true;
q = q.trim();
if (q.startsWith('/') && q.lastIndexOf('/')>0){
const last = q.lastIndexOf('/');
const pattern = q.slice(1,last);
const flags = q.slice(last+1);
try {
const re = new RegExp(pattern, flags);
// RegExp.test is stateful when using the global flag (g).
// Reset lastIndex before each test to avoid skipping matches.
return it => {
try {
if (re.global) re.lastIndex = 0;
return re.test(String(it.name || '')) || re.test(String(it.link || ''));
} catch (e) { return false; }
};
} catch(e){
const lowerErr = q.toLowerCase();
return it => String(it.name || '').toLowerCase().includes(lowerErr) || String(it.link || '').toLowerCase().includes(lowerErr);
}
}
const lower = q.toLowerCase();
// match both the decoded name and the raw link (so numbers inside the link are also found)
return it => String(it.name || '').toLowerCase().includes(lower) || String(it.link || '').toLowerCase().includes(lower);
}
// parse human-friendly size to bytes (supports B, KB, MB, GB, TB)
function parseSize(str){
if (!str) return null;
str = String(str).trim();
if (!str) return null;
const m = str.match(/^([0-9]+(?:[.,][0-9]+)?)\s*(b|kb|mb|gb|tb)?$/i);
if (!m) return null;
let val = parseFloat(m[1].replace(',', '.'));
const unit = (m[2] || '').toLowerCase();
if (unit === 'tb') return Math.round(val * Math.pow(1024,4));
if (unit === 'gb') return Math.round(val * Math.pow(1024,3));
if (unit === 'mb') return Math.round(val * Math.pow(1024,2));
if (unit === 'kb') return Math.round(val * 1024);
return Math.round(val);
}
// sorting state (default: tome descending)
let sortState = { col: 'tome', dir: -1 };
function defaultDirFor(col) { return col === 'tome' ? -1 : 1; }
// wire search input and size inputs to rendering (supports regex via makeFilterFromQuery)
const debRender = debounce(() => renderRows(search.value), 120);
search.addEventListener('input', debRender);
minInput.addEventListener('input', debRender);
maxInput.addEventListener('input', debRender);
clearSizeBtn.addEventListener('click', ()=>{ minInput.value=''; maxInput.value=''; debRender(); });
// make headers sortable
const ths = thead.querySelectorAll('th');
ths.forEach(h => {
const col = h.getAttribute('data-col');
if (!col) return;
h.style.cursor = 'pointer';
h.addEventListener('click', () => {
if (sortState.col === col) {
sortState.dir *= -1;
} else {
sortState.col = col;
sortState.dir = defaultDirFor(col);
}
renderRows(search.value);
});
});
// handle Escape to close
function onEsc(e){ if (e.key === 'Escape' || e.key === 'Esc') { try{ modal.remove(); modal=null; }catch(e){} document.removeEventListener('keydown', onEsc); } }
document.addEventListener('keydown', onEsc);
// initial render
renderRows('');
// clean up when modal is removed by other means
const obs = new MutationObserver(() => { if (!document.body.contains(modal)) { try{ document.removeEventListener('keydown', onEsc); obs.disconnect(); } catch(e){} } });
obs.observe(document.body, { childList: true, subtree: true });
}
function flashButton(el, txt) {
const orig = el.textContent;
el.textContent = txt;
setTimeout(() => el.textContent = orig, 1100);
}
async function copyTextToClipboard(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (e) {}
try {
if (typeof GM_setClipboard !== 'undefined') {
GM_setClipboard(text);
return true;
}
} catch (e) {}
try {
fallbackCopyTextToClipboard(text);
return true;
} catch (e) {}
return false;
}
function fallbackCopyTextToClipboard(text) {
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} ta.remove();
}
function prettySize(bytes) {
try { bytes = parseInt(bytes, 10); if (isNaN(bytes)) return ''; const units = ['B','KB','MB','GB','TB']; let i=0; while(bytes>=1024 && i= 1900 && n <= 2099; }
function isValidTomeNumber(n) {
return Number.isFinite(n) && n >= 1 && n <= MAX_TOME_NUMBER && !isLikelyYear(n);
}
function normalizeForScan(name) {
let s = String(name || '');
// Remove common archive extensions to avoid confusing trailing numbers.
s = s.replace(/\.(?:cbr|cbz|rar|zip|7z|pdf)$/i, '');
// Normalize dashes and remove diacritics when supported.
s = s.replace(DASH_NORMALIZE_RE, '-');
try { s = s.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } catch (e) {}
// Unify frequent separators but keep overall structure.
s = s.replace(/[_]+/g, ' ');
s = s.replace(/[.]+/g, ' ');
s = s.replace(/\s+/g, ' ').trim();
return s.toLowerCase();
}
function formatTome(prefix, n) {
return { display: prefix + String(n).padStart(2, '0'), sortValue: n };
}
function extractTomeNumber(name) {
if (!name) return { display: '', sortValue: Infinity };
const scan = normalizeForScan(name);
if (!scan) return { display: '', sortValue: Infinity };
// 1) Integrales / packs (treated as a special "tome" above normal volumes).
if (INTEGRALE_RE.test(scan)) {
return { display: 'INT', sortValue: INTEGRALE_SORT_VALUE };
}
if (PACK_RE.test(scan) && RANGE_RE.test(scan)) {
return { display: 'PACK', sortValue: PACK_SORT_VALUE };
}
// 2) Explicit tome markers (most reliable).
for (const { re, prefix } of EXPLICIT_TOME_PATTERNS) {
const match = scan.match(re);
if (!match || !match[1]) continue;
const num = parseInt(match[1], 10);
if (!isValidTomeNumber(num)) continue;
return formatTome(prefix, num);
}
// 3) Structured hints like "02 (sur 3)" or "02/03".
const surMatch = scan.match(/(?:^|[^a-z0-9])0*([0-9]{1,3})\s*[\s._\-–—()[\]]*(?:sur|\/)\s*0*([0-9]{1,3})(?!\d)/i);
if (surMatch && surMatch[1]) {
const num = parseInt(surMatch[1], 10);
if (isValidTomeNumber(num)) return formatTome('T', num);
}
// 4) Safe inference: pick the best separated number in range.
const candidateRe = /(^|[^a-z0-9])(0*[0-9]{1,3})(?=([^a-z0-9]|$))/gi;
let best = null;
let m;
while ((m = candidateRe.exec(scan)) !== null) {
const rawDigits = m[2];
const num = parseInt(rawDigits, 10);
if (!isValidTomeNumber(num)) continue;
const startIdx = m.index + (m[1] ? m[1].length : 0);
const endIdx = startIdx + rawDigits.length;
const prevChar = startIdx > 0 ? scan[startIdx - 1] : ' ';
const nextChar = endIdx < scan.length ? scan[endIdx] : ' ';
const beforeWindow = scan.slice(Math.max(0, startIdx - 14), startIdx);
const afterWindow = scan.slice(endIdx, Math.min(scan.length, endIdx + 14));
let score = 0;
// Numbers near the start are more likely to be tomes.
if (startIdx < 48) score += 20;
// Prefer clearly separated tokens.
if (/[^a-z0-9]/.test(prevChar)) score += 12; else score -= 25;
if (/[^a-z0-9]/.test(nextChar)) score += 12; else score -= 25;
// Common tome layout: " - 01 - " or " . 02 . "
if (/(?:^|[\s\[(])[-–—]\s*$/.test(beforeWindow)) score += 16;
if (/^\s*[-–—]/.test(afterWindow)) score += 12;
if (/\(\s*$/.test(beforeWindow) || /\[\s*$/.test(beforeWindow)) score += 8;
// Avoid technical tags like U3840, 1920px, etc.
if (/[a-z]\s*$/.test(beforeWindow) && !/\b(?:t|v|hs)\s*$/.test(beforeWindow)) score -= 10;
if (!best || score > best.score || (score === best.score && startIdx < best.startIdx)) {
best = { num, score, startIdx };
}
}
// Require a minimum confidence to avoid false positives.
if (best && best.score >= 28) {
return formatTome('T', best.num);
}
return { display: '', sortValue: Infinity };
}
function shorten(s) { return s.length>48 ? s.slice(0,42)+'…'+s.slice(-6) : s; }
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function(m){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]}); }
// event: button click
// toggle modal when clicking the button: if open -> close, else open
btn.addEventListener('click', () => {
try {
if (modal && document.body.contains(modal)) { modal.remove(); modal = null; return; }
const items = findEd2kLinks();
if (!items.length) {
// small pulse
try { btn.animate([{ transform: 'scale(1)' }, { transform: 'scale(1.08)' }, { transform: 'scale(1)' }], { duration: 420 }); } catch(e){}
alert('Aucun lien ed2k trouvé sur cette page.');
updateBadge();
return;
}
buildModal(items);
try { updateBadge(); } catch (e) {}
} catch(e) {}
});
// Right-click menu on the button: persistent settings (position, size, show/hide, reset)
btn.addEventListener('contextmenu', (e) => {
e.preventDefault();
const menu = document.createElement('div');
menu.style.position='fixed'; menu.style.zIndex=99999999; menu.style.right='18px'; menu.style.bottom='80px'; menu.style.background='#04252e'; menu.style.border='1px solid rgba(255,255,255,0.04)'; menu.style.padding='10px'; menu.style.borderRadius='10px'; menu.style.minWidth='180px';
// Title
const h = document.createElement('div'); h.textContent = 'Réglages'; h.style.fontWeight='700'; h.style.marginBottom='8px'; menu.appendChild(h);
// Position
const posLabel = document.createElement('div'); posLabel.textContent = 'Position du bouton'; posLabel.style.fontSize='12px'; posLabel.style.color='#cfe8f6'; posLabel.style.marginBottom='6px'; menu.appendChild(posLabel);
const posRow = document.createElement('div'); posRow.style.display='flex'; posRow.style.gap='6px'; posRow.style.marginBottom='8px';
['top-left','top-right','bottom-left','bottom-right'].forEach(p => { const b = document.createElement('button'); b.className='ed2k-btn'; b.textContent = p.replace('-',' '); b.style.flex='1'; b.addEventListener('click', ()=>{ prefs.btnPos = p; savePrefs(prefs); applyButtonPosition(); menu.remove(); }); posRow.appendChild(b); });
menu.appendChild(posRow);
// Size
const sizeLabel = document.createElement('div'); sizeLabel.textContent = 'Taille du bouton'; sizeLabel.style.fontSize='12px'; sizeLabel.style.color='#cfe8f6'; sizeLabel.style.marginBottom='6px'; menu.appendChild(sizeLabel);
const sizeRow = document.createElement('div'); sizeRow.style.display='flex'; sizeRow.style.gap='6px'; sizeRow.style.marginBottom='8px';
[['small','S'],['normal','M'],['large','L']].forEach(([k,l])=>{ const b=document.createElement('button'); b.className='ed2k-btn'; b.textContent = l; b.style.flex='1'; b.addEventListener('click', ()=>{ prefs.btnSize = k; savePrefs(prefs); applyButtonSize(); menu.remove(); }); sizeRow.appendChild(b); });
menu.appendChild(sizeRow);
// Show/hide
const toggle = document.createElement('button'); toggle.className='ed2k-btn'; toggle.textContent = prefs.showButton ? 'Masquer bouton' : 'Afficher bouton'; toggle.style.width='100%'; toggle.style.marginBottom='8px'; toggle.addEventListener('click', ()=>{ prefs.showButton = !prefs.showButton; savePrefs(prefs); btn.style.display = prefs.showButton ? 'flex' : 'none'; menu.remove(); }); menu.appendChild(toggle);
// Reset defaults
const reset = document.createElement('button'); reset.className='ed2k-btn'; reset.textContent='Réinitialiser'; reset.style.width='100%'; reset.addEventListener('click', ()=>{ prefs.btnPos='bottom-right'; prefs.btnSize='normal'; prefs.showButton=true; savePrefs(prefs); applyButtonPosition(); applyButtonSize(); btn.style.display='flex'; menu.remove(); }); menu.appendChild(reset);
document.body.appendChild(menu);
const rm = () => { try{ menu.remove(); }catch(e){} };
setTimeout(() => document.addEventListener('click', rm, { once: true }));
});
// no dynamic theme handling: fixed ocean theme
// expose small API for debugging (optional)
window.__ed2kRevealer = { findEd2kLinks };
// Badge handling: create a small persistent badge inside the floating button and update it
function updateBadge() {
try {
const items = findEd2kLinks();
// If no links found and button not yet appended, don't show button
if (!items || items.length === 0) {
if (buttonAppended) {
// Button was visible, hide badge
let badge = btn.querySelector('.ed2k-badge');
if (badge) badge.style.display = 'none';
}
// Don't append button if no links
return;
}
// Links found: append button if not already done
if (!buttonAppended && document.body) {
document.body.appendChild(btn);
buttonAppended = true;
}
// Update badge
let badge = btn.querySelector('.ed2k-badge');
if (!badge) {
badge = document.createElement('div'); badge.className = 'ed2k-badge';
btn.style.position = btn.style.position || 'fixed';
btn.appendChild(badge);
}
badge.style.display = 'flex';
// show exact number
badge.textContent = String(items.length);
} catch (e) { /* ignore */ }
}
// debounce helper
function debounce(fn, wait){ let t; return function(...a){ clearTimeout(t); t = setTimeout(()=> fn.apply(this,a), wait); }; }
// MutationObserver to update badge automatically when DOM changes (debounced)
try {
const debUpdate = debounce(updateBadge, 400);
const mo = new MutationObserver(debUpdate);
mo.observe(document.body, { childList: true, subtree: true, characterData: true });
// initial update
setTimeout(updateBadge, 300);
} catch(e) { try{ setTimeout(updateBadge, 400); }catch(e){} }
})();