// ==UserScript==
// @name קיצור קישורים אוטומטי בפורומי NodeBB
// @namespace https://github.com/tsoolgee/nodebb-link-shortener
// @version 0.0.3
// @description מדביקים קישור של גוגל דרייב / ג'מבו מייל / מג'יקוד בעורך של פורום NodeBB – והוא מוחלף בשקט בקישור did.li מקוצר
// @author tsoolgee
// @homepageURL https://github.com/tsoolgee/nodebb-link-shortener
// @supportURL https://github.com/tsoolgee/nodebb-link-shortener/issues
// @updateURL https://raw.githubusercontent.com/tsoolgee/nodebb-link-shortener/main/userscript/nodebb-link-shortener.user.js
// @downloadURL https://raw.githubusercontent.com/tsoolgee/nodebb-link-shortener/main/userscript/nodebb-link-shortener.user.js
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addValueChangeListener
// @grant GM_registerMenuCommand
// @connect 6ejpppqpkh.execute-api.eu-west-1.amazonaws.com
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// הגדרות משותפות: ברירות מחדל ועזרים לרשימות אתרים.
// הפלטפורמה מספקת loadSettings() → Promise, saveSettings(s) → Promise, onSettingsChanged(cb).
const DEFAULT_SETTINGS = {
autoDetect: true, // זיהוי אוטומטי של פורומי NodeBB
sites: [], // אתרים שבהם לפעול תמיד, גם בלי זיהוי
excluded: [], // אתרים שבהם לא לפעול אף פעם
domains: [], // דומיינים נוספים שקישורים אליהם יקוצרו
};
function withDefaults(s) {
return Object.assign({}, DEFAULT_SETTINGS, s || {});
}
// "https://www.Example.com/path" → "example.com"
function normalizeHost(s) {
s = String(s || '').trim().toLowerCase();
if (!s) return '';
try { s = new URL(/^[a-z]+:\/\//.test(s) ? s : 'http://' + s).hostname; } catch (e) { return ''; }
return s.replace(/^www\./, '');
}
function parseHostList(text) {
return [...new Set(String(text).split(/[\s,]+/).map(normalizeHost).filter(Boolean))];
}
// דומיין ברשימה תופס גם את תתי-הדומיינים שלו
function hostInList(host, list) {
host = host.toLowerCase().replace(/^www\./, '');
return list.some(d => host === d || host.endsWith('.' + d));
}
// מסך ההגדרות – אותו מסך בדף האפשרויות של התוסף ובחלון של הסקריפט.
const SETTINGS_CSS = `
.nls { direction: rtl; font: 14px/1.5 system-ui, Arial, sans-serif; color: #222; background: #fff;
padding: 18px 20px; max-width: 460px; box-sizing: border-box; }
.nls * { box-sizing: border-box; }
.nls h2 { margin: 0 0 12px; font-size: 18px; color: #407be3; }
.nls .row { margin: 0 0 14px; }
.nls label.chk { display: flex; gap: 8px; align-items: flex-start; cursor: pointer; font-weight: 600; }
.nls label.chk input { margin-top: 4px; }
.nls .title { font-weight: 600; display: block; margin-bottom: 2px; }
.nls .hint { color: #666; font-size: 12px; margin: 0 0 4px; }
.nls textarea { width: 100%; min-height: 64px; direction: ltr; text-align: left; font: 13px/1.4 Consolas, monospace;
padding: 6px 8px; border: 1px solid #bbb; border-radius: 6px; resize: vertical; }
.nls textarea:focus { outline: 2px solid #407be3; border-color: transparent; }
.nls .builtin { direction: ltr; text-align: left; font: 12px Consolas, monospace; color: #555; }
.nls .actions { display: flex; gap: 8px; align-items: center; }
.nls button { background: #407be3; color: #fff; border: 0; padding: 7px 18px; border-radius: 6px; cursor: pointer; font: inherit; }
.nls button.secondary { background: #888; }
.nls .status { color: #2a8a3a; font-size: 13px; }
@media (prefers-color-scheme: dark) {
.nls { background: #1f2329; color: #e6e6e6; }
.nls .hint, .nls .builtin { color: #a0a6ad; }
.nls textarea { background: #15181c; color: #e6e6e6; border-color: #444; }
}
`;
const BUILTIN_DOMAINS = 'drive.google.com, docs.google.com, jumbomail.me, send.magicode.me';
// root: אלמנט או shadowRoot. onClose: אם קיים – מוצג כפתור "סגור".
function renderSettings(root, onClose) {
const style = document.createElement('style');
style.textContent = SETTINGS_CSS;
const box = document.createElement('div');
box.className = 'nls';
box.innerHTML = `
הגדרות קיצור קישורים
אתרים שבהם לפעול תמיד
גם אם הם לא זוהו כפורום NodeBB. אתר אחד בכל שורה, למשל mitmachim.top
אתרים חריגים – לא להחליף בהם
גם אם הם פורום NodeBB.
דומיינים נוספים לקיצור
קישור לאתרים האלה יקוצר, חוץ מקישור לדף הבית שלהם. מובנים כבר:
${BUILTIN_DOMAINS}
${onClose ? '' : ''}
`;
root.appendChild(style);
root.appendChild(box);
const $ = id => box.querySelector('#' + id);
loadSettings().then(raw => {
const s = withDefaults(raw);
$('autoDetect').checked = s.autoDetect;
['sites', 'excluded', 'domains'].forEach(k => { $(k).value = s[k].join('\n'); });
});
$('save').addEventListener('click', () => {
const s = {
autoDetect: $('autoDetect').checked,
sites: parseHostList($('sites').value),
excluded: parseHostList($('excluded').value),
domains: parseHostList($('domains').value),
};
saveSettings(s).then(() => {
['sites', 'excluded', 'domains'].forEach(k => { $(k).value = s[k].join('\n'); });
$('status').textContent = 'נשמר ✓';
setTimeout(() => { $('status').textContent = ''; }, 2000);
});
});
if (onClose) $('close').addEventListener('click', onClose);
}
const API = 'https://6ejpppqpkh.execute-api.eu-west-1.amazonaws.com/Prod/create';
// GM_xmlhttpRequest עוקף את מדיניות האבטחה (CSP) של הפורום
function requestShort(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: API,
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
data: JSON.stringify({ url }),
timeout: 15000,
onload(res) {
if (res.status < 200 || res.status >= 300) return reject(new Error('status ' + res.status));
try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(e); }
},
onerror: () => reject(new Error('network')),
ontimeout: () => reject(new Error('timeout')),
});
});
}
function loadSettings() {
return Promise.resolve(GM_getValue('settings'));
}
function saveSettings(s) {
GM_setValue('settings', s);
return Promise.resolve();
}
// מתעדכן גם כששומרים הגדרות בלשונית אחרת
function onSettingsChanged(cb) {
GM_addValueChangeListener('settings', (name, oldValue, newValue) => cb(newValue));
}
// חלון ההגדרות נפתח מתפריט טמפרמונקי, בתוך shadow DOM כדי שעיצוב הפורום לא ישפיע עליו
function openSettings() {
if (document.getElementById('nls-settings-host')) return;
const host = document.createElement('div');
host.id = 'nls-settings-host';
host.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:16px';
const shadow = host.attachShadow({ mode: 'open' });
const panel = document.createElement('div');
panel.style.cssText = 'border-radius:10px;overflow:auto;max-height:100%;box-shadow:0 8px 30px rgba(0,0,0,.35)';
shadow.appendChild(panel);
const close = () => { host.remove(); document.removeEventListener('keydown', onKey, true); };
const onKey = e => { if (e.key === 'Escape') close(); };
host.addEventListener('click', e => { if (e.composedPath()[0] === host) close(); });
document.addEventListener('keydown', onKey, true);
renderSettings(panel, close);
document.body.appendChild(host);
}
GM_registerMenuCommand('⚙️ הגדרות', openSettings);
// הקוד המשותף לתוסף ולסקריפט. הפלטפורמה מספקת requestShort(url) → Promise<תשובת JSON>.
// אילו קישורים לקצר: רק קישור לקובץ או לתיקייה, לא לדף הבית של האתר.
// כל כלל: דומיין (כולל תתי-דומיינים) + בדיקה על האובייקט URL.
const hasToken = u => u.pathname.split('/').some(s => /^(?=.*[\dA-Z])[\w-]{5,}$/.test(s));
const RULES = [
[['drive.google.com', 'docs.google.com'], u =>
/\/(u\/\d+\/)?d\/(e\/)?[\w-]{10,}|\/folders\/[\w-]{10,}/.test(u.pathname) ||
/^[\w-]{10,}$/.test(u.searchParams.get('id') || '')],
[['drive.usercontent.google.com'], u => /^[\w-]{10,}$/.test(u.searchParams.get('id') || '')],
// מג'יקוד: קישור צפייה (/send-file/file//view) וגם קישור הורדה (כל קישור עם download)
[['magicode.me'], u => /\/send-file\/file\/[^/]+/.test(u.pathname) ||
(/download/i.test(u.pathname + u.search) && !/^\/send-file\/(prep-)?upload\/?$/.test(u.pathname))],
[['jumbomail.me', 'jmbo.me'], hasToken],
];
const URL_RE = /https?:\/\/[^\s<>"'`)\]]+/gi;
const cache = new Map(); // קישור מקורי → Promise של קישור מקוצר
let nodebb = null;
function isNodeBB() {
if (nodebb === null) {
nodebb = !!document.querySelector(
'script[src*="/assets/nodebb.min.js"], link[href*="/assets/client"][href*=".css"], meta[name="generator"][content*="NodeBB" i]'
);
}
return nodebb;
}
let settings = withDefaults();
loadSettings().then(s => { settings = withDefaults(s); });
onSettingsChanged(s => { settings = withDefaults(s); });
// האם לפעול בדף הזה: חריגים קודמים לכול, אחריהם רשימת האתרים, ואז הזיהוי האוטומטי
function isActiveHere() {
const host = location.hostname;
if (hostInList(host, settings.excluded)) return false;
if (hostInList(host, settings.sites)) return true;
return settings.autoDetect && isNodeBB();
}
function shouldShorten(url) {
let u;
try { u = new URL(url); } catch (e) { return false; }
const host = u.hostname.toLowerCase();
if (RULES.some(([domains, test]) => hostInList(host, domains) && test(u))) return true;
// דומיין שהמשתמש הוסיף: כל קישור חוץ מדף הבית
return hostInList(host, settings.domains) && (u.pathname.replace(/\/+$/, '') !== '' || u.search !== '');
}
function shorten(url) {
if (cache.has(url)) return cache.get(url);
const p = requestShort(url).then(r => {
let s = r && (r.shortUrl || r.short_url || r.shorturl || r.url);
if (!s && r && r.code) s = 'https://did.li/' + r.code;
if (!s) throw new Error('no short url');
return s;
});
p.catch(() => cache.delete(url)); // כישלון – לנסות שוב בהדבקה הבאה
cache.set(url, p);
return p;
}
// מחליף בתיבה את המקור במקוצר, שומר על מיקום הסמן ומעדכן את התצוגה המקדימה
function replaceInField(el, original, short) {
const value = el.value;
if (!value.includes(original)) return;
const caret = el.selectionStart;
const before = value.slice(0, caret);
const shiftedCaret = caret + (before.split(original).length - 1) * (short.length - original.length);
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
setter.call(el, value.split(original).join(short));
if (document.activeElement === el) el.setSelectionRange(shiftedCaret, shiftedCaret);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function onPaste(e) {
const el = e.target;
if (!(el instanceof HTMLTextAreaElement) || !isActiveHere()) return;
const text = e.clipboardData && e.clipboardData.getData('text/plain');
if (!text) return;
const urls = [...new Set((text.match(URL_RE) || []).map(u => u.replace(/[.,;:!?]+$/, '')))]
.filter(shouldShorten);
// ההדבקה עצמה עוברת כרגיל; ההחלפה קורית ברקע כשהקיצור חוזר
urls.forEach(u => shorten(u).then(s => replaceInField(el, u, s)).catch(() => {}));
}
document.addEventListener('paste', onPaste, true);
})();