// sortof-shared - D proxy, pure helpers, and leaf components // Loaded before sortof-app.jsx; defines globals that App and extracted components reference. window._liveSortData = null; const D = new Proxy({}, { get(_, key) { const src = window._liveSortData ?? window.SORTOF_DATA; return src ? src[key] : undefined; } }); // Make globally visible for sortof-app.jsx (loaded as separate script). Object.assign(window, { D, _liveSortData: null, parseWorkshopInput, buildModsLine, _applyAppendWsid, _applyStripWsid, _applyEnsureAdded, _applyEnsureRemoved, _applyEnsureSwapped, pollJobOnce, pollJobLoop, isRadioMode, defaultSelectionForBranches, fullSelectionForWsid, COLUMN_COUNT, wsidUrl, WsidLink, IconCopy, IconCheck, IconGit, IconLink, CopyBtn, ShareBtn, rowStateForMod, WarnMsg, warnAccent, actionStaged, unknownRemovalWarnings, failedIndexWarnings }); function parseWorkshopInput(text) { if (!text) return []; const cleaned = String(text).replace(/^\s*(WorkshopItems|Mods|Map)\s*=\s*/gim, ''); const ids = cleaned.match(/\b\d{7,12}\b/g) || []; return Array.from(new Set(ids)); } function buildModsLine(ids, mode) { if (!ids || !ids.length) return ''; if (mode === 'B42') return '\\' + ids.join(';\\'); return ids.join(';'); } function _applyAppendWsid(input, wsid) { const trimmed = (input || '').replace(/\s+$/, ''); const sep = trimmed ? '\n' : ''; return trimmed + sep + wsid; } function _applyStripWsid(input, wsid) { return (input || '').replace( new RegExp(`(?:^|(?<=[\\s;,]))${wsid}(?=$|[\\s;,])`, 'g'), '' ).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim(); } function _applyEnsureAdded(input, wsid) { const wid = String(wsid); return parseWorkshopInput(input).includes(wid) ? input : _applyAppendWsid(input, wid); } function _applyEnsureRemoved(input, wsid) { const w = String(wsid); return parseWorkshopInput(input).includes(w) ? _applyStripWsid(input, w) : input; } function _applyEnsureSwapped(input, from, to) { const f = String(from); const t = String(to); const existing = parseWorkshopInput(input); if (!existing.includes(f)) return input; if (existing.includes(t)) return _applyStripWsid(input, f); return input.replace(new RegExp(`(?<=^|[\\s;,])${f}(?=$|[\\s;,])`, 'g'), t); } const POLL_INTERVAL_MS = 2500; async function pollJobOnce(jobId) { try { const res = await fetch(`/api/jobs/${jobId}`); if (res.status === 404) return { kind: 'expired' }; if (!res.ok) return { kind: 'error', status: res.status }; const json = await res.json(); return { kind: 'ok', body: json }; } catch (e) { console.warn('pollJobOnce: network error', e); return { kind: 'error', status: 0 }; } } function pollJobLoop(jobId, signal, onTick) { return new Promise((resolve) => { let timer = null; async function tick() { if (signal.aborted) { if (timer) clearTimeout(timer); resolve({ kind: 'aborted' }); return; } const r = await pollJobOnce(jobId); if (signal.aborted) { resolve({ kind: 'aborted' }); return; } onTick(r); if (r.kind === 'expired' || r.kind === 'error') { resolve(r); return; } const phase = r.body.phase; if (phase === 'done' || phase === 'failed') { resolve(r); return; } timer = setTimeout(tick, POLL_INTERVAL_MS); } tick(); }); } function isRadioMode(branches) { if (!branches || branches.length < 2) return false; const ids = new Set(branches.map(b => b.modId)); return branches.some(b => (b.conflicts || []).some(c => ids.has(c))); } function defaultSelectionForBranches(branches, activeSet) { if (!branches || !branches.length) return []; if (activeSet && activeSet.size) { const active = branches.filter(b => activeSet.has(b.modId)).map(b => b.modId); if (active.length) return active; } return [branches[0].modId]; } function fullSelectionForWsid(wsid, selections) { if (selections && selections[wsid] !== undefined) return selections[wsid]; const active = new Set(D.SORTED_ORDER || []); return (D.MOD_DB || []) .filter(m => String(m.wsid) === String(wsid) && active.has(m.modId)) .map(m => m.modId); } const COLUMN_COUNT = 6; function wsidUrl(wsid) { return `https://steamcommunity.com/sharedfiles/filedetails/?id=${wsid}`; } function WsidLink({ wsid, children, className }) { if (!wsid) return {children ?? wsid}; return {children ?? wsid}; } function IconCopy() { return ; } function IconCheck() { return ; } function IconGit() { return ; } function IconLink() { return ; } function CopyBtn({ text, label, minimal }) { const [copied, setCopied] = React.useState(false); const doCopy = async () => { try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1800); } catch { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); setCopied(true); setTimeout(() => setCopied(false), 1800); } }; if (minimal) return ; return ; } function ShareBtn({ jobId }) { const [saving, setSaving] = React.useState(false); const [code, setCode] = React.useState(null); if (!jobId) return null; const onClick = async () => { setSaving(true); try { const res = await fetch(`/api/jobs/${jobId}/share`, { method: 'POST' }); if (res.ok) { const json = await res.json(); setCode(json.code); } else { setCode('error'); } } catch { setCode('error'); } setSaving(false); }; if (code) return sortof.indifferentketchup.com/s/{code}; return ; } function rowStateForMod(modId) { const warns = (D.WARNINGS || []); for (const w of warns) { if (!w || !w.msg) continue; const msg = w.msg; const m = msg.match(/^([A-Za-z0-9_+\-]{2,})\s+(requires|marked|and\b)/) || msg.match(/cycle:\s*([A-Za-z0-9_+\-]+)/); if (!m || m[1] !== modId) continue; if (w.level === 'red') return 'error'; if (w.level === 'amber') return 'warning'; } return 'resolved'; } const _WARN_PRIORITY = { cycle: 10, error: 20, incompatible: 30, 'build-mismatch': 35, 'duplicate-mod_id': 37, missing: 40, 'missing-addon': 45, 'auto-picked-branch': 50, 'unmatched-addons': 55, 'rules-applied': 60, info: 70 }; function _warnSortKey(w) { return _WARN_PRIORITY[w.tag] ?? _WARN_PRIORITY.info; } function WarnMsg({ msg, wsid }) { if (!msg) return null; // modId -> wsid map so mod-name tokens in a message become Steam links too. const modMap = {}; for (const m of (D.MOD_DB || [])) { if (m && m.modId && m.wsid && !(m.modId in modMap)) modMap[m.modId] = String(m.wsid); } const link = (key, href, cls, text) => ( {text} ); // Split a run of text into plain strings, wsid links (7-12 digits, with ↗), and known-modId links. const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const modIds = Object.keys(modMap).sort((a, b) => b.length - a.length); // longest first so prefixes don't win const parts = ['\\b\\d{7,12}\\b'].concat(modIds.map(id => '\\b' + escapeRe(id) + '\\b')); const re = new RegExp('(' + parts.join('|') + ')', 'g'); const tokenize = (text, keyBase) => { const out = []; text.split(re).forEach((piece, i) => { if (!piece) return; if (/^\d{7,12}$/.test(piece)) out.push(link(`${keyBase}-${i}`, wsidUrl(piece), 'w-msg-link', piece)); else if (piece in modMap) out.push(link(`${keyBase}-${i}`, wsidUrl(modMap[piece]), 'w-msg-title-link', piece)); else out.push(piece); }); return out; }; // Messages shaped " (<wsid>, ...)" make the leading mod title a link to the same item. // Split on the "(<wsid>" marker so titles containing parentheses stay intact. const marker = wsid ? '(' + String(wsid) : ''; const idx = marker ? msg.indexOf(marker) : -1; const nodes = []; if (idx > 0) { const title = msg.slice(0, idx).replace(/\s+$/, ''); nodes.push(link('title', wsidUrl(wsid), 'w-msg-title-link', title)); nodes.push(...tokenize(msg.slice(title.length), 'rest')); } else { nodes.push(...tokenize(msg, 'm')); } return <span className="w-msg">{nodes}</span>; } function warnAccent(w) { if (Array.isArray(w.alternatives) && w.alternatives.length) return 'amber'; const types = new Set((Array.isArray(w.actions) ? w.actions : []).map(a => a.type)); if (types.has('add-wsid')) return 'blue'; if (types.has('remove-wsid')) return 'red'; if (types.has('swap-wsid')) return 'green'; return w.level === 'red' ? 'red' : 'amber'; } function actionStaged(a, inputWsids) { if (a.type === 'add-wsid') return inputWsids.has(String(a.wsid)); if (a.type === 'remove-wsid') return !inputWsids.has(String(a.wsid)); if (a.type === 'swap-wsid') return !inputWsids.has(String(a.from)) && inputWsids.has(String(a.to)); return false; } function unknownRemovalWarnings(unknownWsids) { return (unknownWsids || []).map((w) => ({ tag: 'unknown', level: 'amber', type: 'remove-wsid', msg: `unknown wsid ${w}: not included in sort; consider removing it from your input.`, wsid: w, })); } function failedIndexWarnings(failedWsids) { return (failedWsids || []).map((w) => ({ tag: 'failed', level: 'red', type: 'retry', msg: `wsid ${w} failed: Steam rejected or returned no data.`, wsid: w, action: { type: 'retry-wsid', wsid: w }, })); }