diff --git a/api/mlos_sort.py b/api/mlos_sort.py
index 0d8d15b..af5b8fe 100644
--- a/api/mlos_sort.py
+++ b/api/mlos_sort.py
@@ -41,16 +41,21 @@ PREORDER: Dict[str, int] = {
# Project-specific forced order: tsarslib is a foundational lib that many
# vehicle/map mods require, so it must precede them. AquatsarYachtClub +
# AquatsarRVAddon + ProjectRVInterior42 form an authored cluster whose
- # interior overlays only render correctly in this exact order. Slots 4-7
- # land them immediately after the management tools, before any category-
- # sorted content.
+ # interior overlays only render correctly in this exact order, and
+ # RVInteriorExpansion + Part2 (wsids 3618427553 / 3622163276) chain off
+ # PROJECTRVInterior42 — they don't declare loadAfter in their own mod.info
+ # and their category="undefined" drifts them to the end of MODS_LINE under
+ # category sort, so PREORDER pins them adjacent. Slots 4-9 land the cluster
+ # immediately after the management tools, before any category-sorted content.
"tsarslib": 4,
"AquatsarYachtClubB42": 5,
"AquatsarRVAddon": 6,
"PROJECTRVInterior42": 7,
+ "RVInteriorExpansion": 8,
+ "RVInteriorExpansionPart2": 9,
# damnlib (wsid 3171167894) — same nature as tsarslib: foundational lib
- # consumed by many B42 mods. Slots after the Aquatsar block.
- "damnlib": 8,
+ # consumed by many B42 mods. Slots after the Aquatsar/RV cluster.
+ "damnlib": 10,
}
RAW_CATEGORY_ORDER: List[str] = [
diff --git a/data/rules/curated/rv_expansion.txt b/data/rules/curated/rv_expansion.txt
index b3f4b70..16a5905 100644
--- a/data/rules/curated/rv_expansion.txt
+++ b/data/rules/curated/rv_expansion.txt
@@ -11,9 +11,12 @@
;
; Authored ordering: PROJECTRVInterior42 → RVInteriorExpansion → RVInteriorExpansionPart2.
; The expansion mods don't declare these loadAfter relationships in their own
-; mod.info, so without these rules sortof has nothing to base the cluster
-; ordering on (PROJECTRVInterior42 is in PREORDER slot 7 already; expansions
-; just need to chain off of it).
+; mod.info. The actual adjacent placement is now enforced by PREORDER slots
+; 8/9 in mlos_sort.py — `loadAfter` only constrains topo order and lets
+; category-sort drift the expansions to the end of MODS_LINE under
+; "undefined". The rules below remain as documentation of the intended chain
+; AND so the `rules-applied` warning still fires when either expansion wsid
+; is in the user's input.
[RVInteriorExpansion]
loadAfter=PROJECTRVInterior42
diff --git a/frontend/index.html b/frontend/index.html
index 4143486..3449ea2 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1040,6 +1040,59 @@
color: var(--brand-ink);
border-color: var(--error);
}
+ /* Auto-fix bar: 3 batched-action buttons that sit ABOVE the warnings panel.
+ * Each variant mirrors the corresponding inline .warn-action palette so
+ * "add deps" reads as info-blue, "fix mismatched" as success-green, and
+ * "remove incorrect" as error-red. Stages actions like inline buttons; sort
+ * is NOT fired (consistent with the staged-warning UI). */
+ .auto-fix-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 10px;
+ padding: 0;
+ }
+ .auto-fix-btn {
+ appearance: none;
+ border: 1px solid;
+ font-family: var(--mono);
+ font-size: 12.5px;
+ font-weight: 600;
+ padding: 7px 14px;
+ border-radius: var(--radius);
+ cursor: pointer;
+ transition: color .12s, border-color .12s, background .12s, transform .08s;
+ }
+ .auto-fix-btn:active { transform: translateY(1px); }
+ .auto-fix-btn.add {
+ border-color: var(--acc-blue);
+ background: var(--acc-blue-bg, rgba(70,140,220,0.12));
+ color: var(--acc-blue);
+ }
+ .auto-fix-btn.add:hover {
+ background: var(--acc-blue);
+ color: var(--brand-ink);
+ }
+ .auto-fix-btn.swap {
+ border-color: var(--success, var(--acc-green));
+ background: var(--success-bg, rgba(80,180,120,0.12));
+ color: var(--success, var(--acc-green));
+ }
+ .auto-fix-btn.swap:hover {
+ background: var(--success, var(--acc-green));
+ color: var(--brand-ink);
+ }
+ .auto-fix-btn.remove {
+ border-color: var(--error);
+ background: transparent;
+ color: var(--error);
+ }
+ .auto-fix-btn.remove:hover {
+ background: var(--error);
+ color: var(--brand-ink);
+ border-color: var(--error);
+ }
+
/* Staged warning row: edit applied to input but not yet sorted. Subtle —
* strike + dim — so the row stays readable but visibly "addressed". The
* action button itself flips to the success palette via .warn-action.staged
diff --git a/frontend/sortof-app.jsx b/frontend/sortof-app.jsx
index a4ff0e2..09aa68e 100644
--- a/frontend/sortof-app.jsx
+++ b/frontend/sortof-app.jsx
@@ -35,6 +35,38 @@ function buildModsLine(ids, mode) {
return ids.join(';') + ';';
}
+// Pure helpers for input-textarea mutations. Used by both the per-warning
+// toggle handlers (onAddWsid etc.) and the batch auto-fix handlers. Pure so
+// they can be folded inside a functional setInput(prev => …) call without
+// stale-closure races.
+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; // nothing to swap from
+ if (existing.includes(t)) return _applyStripWsid(input, f); // target present, just drop from
+ return input.replace(new RegExp(`(?<=^|[\\s;,])${f}(?=$|[\\s;,])`, 'g'), t);
+}
+
// Spec B+F: poll a job. Resolves on terminal phase or AbortSignal.
const POLL_INTERVAL_MS = 2500;
@@ -684,6 +716,70 @@ function WarnRow({ w, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveW
);
}
+// Auto-fix bar: 3 batched-action buttons that sit ABOVE the warnings panel.
+// Each button stages every eligible action of its type in a single pass; sort
+// is NOT fired (consistent with the staged-warning UI). A warning lives in
+// exactly one bucket: missing-dep → Add, build-mismatch w/ swap → Fix,
+// build-mismatch w/o swap → Remove.
+function AutoFixBar({ items, inputWsids, onAddDeps, onFixMismatches, onRemoveIncorrect }) {
+ if (!items || items.length === 0) return null;
+
+ let addCount = 0, swapCount = 0, removeCount = 0;
+ for (const w of items) {
+ if (!Array.isArray(w.actions)) continue;
+ if (w.tag === 'missing') {
+ // Eligible if any add-wsid target isn't yet in input.
+ if (w.actions.some(a => a.type === 'add-wsid' && a.wsid && !inputWsids.has(String(a.wsid)))) {
+ addCount++;
+ }
+ } else if (w.tag === 'build-mismatch') {
+ const swap = w.actions.find(a => a.type === 'swap-wsid' && a.from && a.to);
+ if (swap) {
+ // Eligible if from is present and to is absent.
+ if (inputWsids.has(String(swap.from)) && !inputWsids.has(String(swap.to))) {
+ swapCount++;
+ }
+ } else {
+ const rem = w.actions.find(a => a.type === 'remove-wsid' && a.wsid);
+ if (rem && inputWsids.has(String(rem.wsid))) {
+ removeCount++;
+ }
+ }
+ }
+ }
+
+ if (addCount === 0 && swapCount === 0 && removeCount === 0) return null;
+
+ return (
+
+ {addCount > 0 && (
+
+ )}
+ {swapCount > 0 && (
+
+ )}
+ {removeCount > 0 && (
+
+ )}
+
+ );
+}
+
function Warnings({ items, defaultOpen = true, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
const [open, setOpen] = useState(defaultOpen);
if (!items || items.length === 0) return null;
@@ -943,7 +1039,7 @@ function BuildToggle({ value, onChange }) {
);
}
-function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onRetry, previousResult, diffOpen, setDiffOpen }) {
+function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onAutoFixAddDeps, onAutoFixSwaps, onAutoFixRemoves, onRetry, previousResult, diffOpen, setDiffOpen }) {
// Phase-state mapping: legacy `success` ≈ B+F `done`; legacy `partial` ≈ B+F `queued`/`draining`.
const isTerminalDone = state === 'success' || state === 'done';
const isInflightPartial = state === 'partial' || state === 'queued' || state === 'draining';
@@ -958,16 +1054,25 @@ function RightColumn({ state, counts, progress, emptyVariant, successVariant, mo
* apply to are cached, so truncating to slice(0,1) was hiding load-
* order issues from users mid-poll. */}
{showWarnings && (
-
+ <>
+
+
+ >
)}
{/* Cold cache banner */}
@@ -1373,80 +1478,98 @@ function App() {
};
// Append a wsid to the input textarea. Idempotent toggle: a second click
- // on the same [add wsid] button removes the wsid (i.e., undoes the stage).
- // Sort is NOT fired here — the user collects edits, then clicks sort.
- // Used by the missing-dep warning's [add modId] action button.
+ // on the same [add wsid] button removes the wsid (undoes the stage).
+ // Functional setInput so the auto-fix batch handlers can chain mutations
+ // without stale-closure races. Sort is NOT fired here.
const onAddWsid = (wsid /*, modId */) => {
if (!wsid) return;
- const wid = String(wsid);
- const existing = parseWorkshopInput(input);
- if (existing.includes(wid)) {
- const newInput = (input || '').replace(
- new RegExp(`(?:^|(?<=[\\s;,]))${wid}(?=$|[\\s;,])`, 'g'),
- ''
- ).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
- setInput(newInput);
- return;
- }
- const trimmed = (input || '').replace(/\s+$/, '');
- const sep = trimmed ? '\n' : '';
- setInput(trimmed + sep + wid);
+ setInput(prev => {
+ const wid = String(wsid);
+ return parseWorkshopInput(prev).includes(wid)
+ ? _applyStripWsid(prev, wid)
+ : _applyAppendWsid(prev, wid);
+ });
};
- // Remove a wsid from the input textarea token-by-token (preserving the
- // user's separators). Idempotent toggle: a second click on the same
- // [✕ remove] button re-appends the wsid. No sort fired.
+ // Remove a wsid from the input. Idempotent toggle: a second click re-appends.
const onRemoveWsid = (wsid) => {
if (!wsid) return;
- const w = String(wsid);
- const existing = parseWorkshopInput(input);
- if (!existing.includes(w)) {
- const trimmed = (input || '').replace(/\s+$/, '');
- const sep = trimmed ? '\n' : '';
- setInput(trimmed + sep + w);
- return;
- }
- const newInput = (input || '').replace(
- new RegExp(`(?:^|(?<=[\\s;,]))${w}(?=$|[\\s;,])`, 'g'),
- ''
- ).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
- setInput(newInput);
+ setInput(prev => {
+ const w = String(wsid);
+ return parseWorkshopInput(prev).includes(w)
+ ? _applyStripWsid(prev, w)
+ : _applyAppendWsid(prev, w);
+ });
};
- // Replace `from` with `to` in the input textarea. Idempotent toggle: if the
- // input is already in the swapped state (from absent, to present), a second
- // click reverses to the original (to → from). No sort fired. Used by the
- // build-mismatch warning's [swap to ] button (e.g., tsarslib
- // at 2392709985 ↔ 3402491515). Preserves the user's separators.
+ // Replace `from` with `to` in the input. Idempotent toggle: if input is
+ // already in the swapped state (from absent, to present), reverse it.
const onSwapWsid = (from, to) => {
if (!from || !to) return;
- const f = String(from);
- const t = String(to);
- const existing = parseWorkshopInput(input);
- // Already-swapped state → reverse direction.
- if (!existing.includes(f) && existing.includes(t)) {
- const newInput = (input || '').replace(
- new RegExp(`(?<=^|[\\s;,])${t}(?=$|[\\s;,])`, 'g'),
- f
- );
- setInput(newInput);
- return;
- }
- if (!existing.includes(f)) return;
- if (existing.includes(t)) {
- // Target already present: just remove `from`. Otherwise we'd dupe.
- const newInput = (input || '').replace(
- new RegExp(`(?:^|(?<=[\\s;,]))${f}(?=$|[\\s;,])`, 'g'),
- ''
- ).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
- setInput(newInput);
- return;
- }
- const newInput = (input || '').replace(
- new RegExp(`(?<=^|[\\s;,])${f}(?=$|[\\s;,])`, 'g'),
- t
- );
- setInput(newInput);
+ setInput(prev => {
+ const f = String(from);
+ const t = String(to);
+ const existing = parseWorkshopInput(prev);
+ if (!existing.includes(f) && existing.includes(t)) {
+ // Reverse a previously-applied swap.
+ return prev.replace(new RegExp(`(?<=^|[\\s;,])${t}(?=$|[\\s;,])`, 'g'), f);
+ }
+ return _applyEnsureSwapped(prev, f, t);
+ });
+ };
+
+ // Auto-fix: stage every `add-wsid` action on `missing` warnings whose target
+ // isn't yet in input. Single batched setInput so all adds land in one pass.
+ // Sort is NOT fired — pending-cue + strikethroughs show the staged edits.
+ const onAutoFixAddDeps = () => {
+ const warns = (_liveSortData && _liveSortData.WARNINGS) || [];
+ setInput(prev => {
+ let working = prev;
+ for (const w of warns) {
+ if (w.tag !== 'missing' || !Array.isArray(w.actions)) continue;
+ for (const a of w.actions) {
+ if (a.type === 'add-wsid' && a.wsid) {
+ working = _applyEnsureAdded(working, a.wsid);
+ }
+ }
+ }
+ return working;
+ });
+ };
+
+ // Auto-fix: stage every `swap-wsid` action on `build-mismatch` warnings.
+ // One swap per warning (warnings ship at most one swap action). Skips swaps
+ // already in their applied state (from absent, to present).
+ const onAutoFixSwaps = () => {
+ const warns = (_liveSortData && _liveSortData.WARNINGS) || [];
+ setInput(prev => {
+ let working = prev;
+ for (const w of warns) {
+ if (w.tag !== 'build-mismatch' || !Array.isArray(w.actions)) continue;
+ const swap = w.actions.find(a => a.type === 'swap-wsid' && a.from && a.to);
+ if (!swap) continue;
+ working = _applyEnsureSwapped(working, swap.from, swap.to);
+ }
+ return working;
+ });
+ };
+
+ // Auto-fix: stage `remove-wsid` only on `build-mismatch` warnings that have
+ // NO `swap-wsid` (i.e., no B42 successor exists — drop is the only option).
+ // Warnings with both swap+remove are handled by onAutoFixSwaps and skipped here.
+ const onAutoFixRemoves = () => {
+ const warns = (_liveSortData && _liveSortData.WARNINGS) || [];
+ setInput(prev => {
+ let working = prev;
+ for (const w of warns) {
+ if (w.tag !== 'build-mismatch' || !Array.isArray(w.actions)) continue;
+ if (w.actions.some(a => a.type === 'swap-wsid')) continue; // handled by Fix
+ const rem = w.actions.find(a => a.type === 'remove-wsid' && a.wsid);
+ if (!rem) continue;
+ working = _applyEnsureRemoved(working, rem.wsid);
+ }
+ return working;
+ });
};
// Branch-warning button: lock the wsid to the chosen mod_id and resort.
@@ -1938,6 +2061,9 @@ function App() {
onPickBranch={onPickBranch}
onSwapWsid={onSwapWsid}
onRemoveWsid={onRemoveWsid}
+ onAutoFixAddDeps={onAutoFixAddDeps}
+ onAutoFixSwaps={onAutoFixSwaps}
+ onAutoFixRemoves={onAutoFixRemoves}
onRetry={onRetry}
previousResult={previousResultRef.current}
diffOpen={diffOpen}
diff --git a/worker/mlos_sort.py b/worker/mlos_sort.py
index b739105..b5b7d2b 100644
--- a/worker/mlos_sort.py
+++ b/worker/mlos_sort.py
@@ -41,16 +41,21 @@ PREORDER: Dict[str, int] = {
# Project-specific forced order: tsarslib is a foundational lib that many
# vehicle/map mods require, so it must precede them. AquatsarYachtClub +
# AquatsarRVAddon + ProjectRVInterior42 form an authored cluster whose
- # interior overlays only render correctly in this exact order. Slots 4-7
- # land them immediately after the management tools, before any category-
- # sorted content.
+ # interior overlays only render correctly in this exact order, and
+ # RVInteriorExpansion + Part2 (wsids 3618427553 / 3622163276) chain off
+ # PROJECTRVInterior42 — they don't declare loadAfter in their own mod.info
+ # and their category="undefined" drifts them to the end of MODS_LINE under
+ # category sort, so PREORDER pins them adjacent. Slots 4-9 land the cluster
+ # immediately after the management tools, before any category-sorted content.
"tsarslib": 4,
"AquatsarYachtClubB42": 5,
"AquatsarRVAddon": 6,
"PROJECTRVInterior42": 7,
+ "RVInteriorExpansion": 8,
+ "RVInteriorExpansionPart2": 9,
# damnlib (wsid 3171167894) — same nature as tsarslib: foundational lib
- # consumed by many B42 mods. Slots after the Aquatsar block.
- "damnlib": 8,
+ # consumed by many B42 mods. Slots after the Aquatsar/RV cluster.
+ "damnlib": 10,
}
RAW_CATEGORY_ORDER: List[str] = [