mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-24 11:30:12 +01:00
feat: Add include/exclude card lists feature with web UI, validation, fuzzy matching, and JSON persistence (ALLOW_MUST_HAVES=1)
This commit is contained in:
parent
7ef45252f7
commit
0516260304
39 changed files with 3672 additions and 626 deletions
|
|
@ -52,6 +52,7 @@ SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False)
|
|||
ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), False)
|
||||
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
||||
ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
||||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), False)
|
||||
|
||||
# Theme default from environment: THEME=light|dark|system (case-insensitive). Defaults to system.
|
||||
_THEME_ENV = (os.getenv("THEME") or "").strip().lower()
|
||||
|
|
@ -68,6 +69,7 @@ templates.env.globals.update({
|
|||
"enable_themes": ENABLE_THEMES,
|
||||
"enable_pwa": ENABLE_PWA,
|
||||
"enable_presets": ENABLE_PRESETS,
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"default_theme": DEFAULT_THEME,
|
||||
})
|
||||
|
||||
|
|
@ -149,6 +151,7 @@ async def status_sys():
|
|||
"ENABLE_THEMES": bool(ENABLE_THEMES),
|
||||
"ENABLE_PWA": bool(ENABLE_PWA),
|
||||
"ENABLE_PRESETS": bool(ENABLE_PRESETS),
|
||||
"ALLOW_MUST_HAVES": bool(ALLOW_MUST_HAVES),
|
||||
"DEFAULT_THEME": DEFAULT_THEME,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
from fastapi import APIRouter, Request, Form, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from ..app import ALLOW_MUST_HAVES # Import feature flag
|
||||
from ..services.build_utils import (
|
||||
step5_ctx_from_result,
|
||||
step5_error_ctx,
|
||||
|
|
@ -301,6 +302,7 @@ async def build_new_modal(request: Request) -> HTMLResponse:
|
|||
"brackets": orch.bracket_options(),
|
||||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -437,6 +439,8 @@ async def build_new_submit(
|
|||
multi_choice_id: str | None = Form(None),
|
||||
multi_count: int | None = Form(None),
|
||||
multi_thrumming: str | None = Form(None),
|
||||
# Must-haves/excludes (optional)
|
||||
exclude_cards: str = Form(""),
|
||||
) -> HTMLResponse:
|
||||
"""Handle New Deck modal submit and immediately start the build (skip separate review page)."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
|
@ -451,6 +455,7 @@ async def build_new_submit(
|
|||
"brackets": orch.bracket_options(),
|
||||
"labels": orch.ideal_labels(),
|
||||
"defaults": orch.ideal_defaults(),
|
||||
"allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag
|
||||
"form": {
|
||||
"name": name,
|
||||
"commander": commander,
|
||||
|
|
@ -462,6 +467,7 @@ async def build_new_submit(
|
|||
"combo_count": combo_count,
|
||||
"combo_balance": (combo_balance or "mix"),
|
||||
"prefer_combos": bool(prefer_combos),
|
||||
"exclude_cards": exclude_cards or "",
|
||||
}
|
||||
}
|
||||
resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx)
|
||||
|
|
@ -568,6 +574,43 @@ async def build_new_submit(
|
|||
del sess["mc_applied_key"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process exclude cards (M0.5: Phase 1 - Exclude Only)
|
||||
try:
|
||||
from deck_builder.include_exclude_utils import parse_card_list_input, IncludeExcludeDiagnostics
|
||||
|
||||
# Clear any old exclude data
|
||||
for k in ["exclude_cards", "exclude_diagnostics"]:
|
||||
if k in sess:
|
||||
del sess[k]
|
||||
|
||||
if exclude_cards and exclude_cards.strip():
|
||||
# Parse the exclude list
|
||||
exclude_list = parse_card_list_input(exclude_cards.strip())
|
||||
|
||||
# Store in session for the build engine
|
||||
sess["exclude_cards"] = exclude_list
|
||||
|
||||
# Create diagnostics (for future status display)
|
||||
diagnostics = IncludeExcludeDiagnostics(
|
||||
missing_includes=[],
|
||||
ignored_color_identity=[],
|
||||
illegal_dropped=[],
|
||||
illegal_allowed=[],
|
||||
excluded_removed=exclude_list,
|
||||
duplicates_collapsed={},
|
||||
include_added=[],
|
||||
include_over_ideal={},
|
||||
fuzzy_corrections={},
|
||||
confirmation_needed=[],
|
||||
list_size_warnings={"excludes_count": len(exclude_list), "excludes_limit": 15}
|
||||
)
|
||||
sess["exclude_diagnostics"] = diagnostics.__dict__
|
||||
except Exception as e:
|
||||
# If exclude parsing fails, log but don't block the build
|
||||
import logging
|
||||
logging.warning(f"Failed to parse exclude cards: {e}")
|
||||
|
||||
# Clear any old staged build context
|
||||
for k in ["build_ctx", "locks", "replace_mode"]:
|
||||
if k in sess:
|
||||
|
|
@ -2526,6 +2569,10 @@ async def build_permalink(request: Request):
|
|||
},
|
||||
"locks": list(sess.get("locks", [])),
|
||||
}
|
||||
|
||||
# Add exclude_cards if feature is enabled and present
|
||||
if ALLOW_MUST_HAVES and sess.get("exclude_cards"):
|
||||
payload["exclude_cards"] = sess.get("exclude_cards")
|
||||
try:
|
||||
import base64
|
||||
import json as _json
|
||||
|
|
@ -2559,6 +2606,11 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
|
|||
sess["use_owned_only"] = bool(flags.get("owned_only"))
|
||||
sess["prefer_owned"] = bool(flags.get("prefer_owned"))
|
||||
sess["locks"] = list(data.get("locks", []))
|
||||
|
||||
# Import exclude_cards if feature is enabled and present
|
||||
if ALLOW_MUST_HAVES and data.get("exclude_cards"):
|
||||
sess["exclude_cards"] = data.get("exclude_cards")
|
||||
|
||||
sess["last_step"] = 4
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -2578,3 +2630,42 @@ async def build_from(request: Request, state: str | None = None) -> HTMLResponse
|
|||
})
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/validate/exclude_cards")
|
||||
async def validate_exclude_cards(
|
||||
request: Request,
|
||||
exclude_cards: str = Form(default=""),
|
||||
commander: str = Form(default="")
|
||||
):
|
||||
"""Validate exclude cards list and return diagnostics."""
|
||||
if not ALLOW_MUST_HAVES:
|
||||
return JSONResponse({"error": "Feature not enabled"}, status_code=404)
|
||||
|
||||
try:
|
||||
from deck_builder.include_exclude_utils import parse_card_list_input
|
||||
|
||||
# Parse the input
|
||||
card_list = parse_card_list_input(exclude_cards)
|
||||
|
||||
# Basic validation
|
||||
total_count = len(card_list)
|
||||
max_excludes = 15
|
||||
|
||||
# For now, just return count and limit info
|
||||
# Future: add fuzzy matching validation, commander color identity checks
|
||||
result = {
|
||||
"count": total_count,
|
||||
"limit": max_excludes,
|
||||
"over_limit": total_count > max_excludes,
|
||||
"cards": card_list[:10] if len(card_list) <= 10 else card_list[:7] + ["..."], # Show preview
|
||||
"warnings": []
|
||||
}
|
||||
|
||||
if total_count > max_excludes:
|
||||
result["warnings"].append(f"Too many excludes: {total_count}/{max_excludes}")
|
||||
|
||||
return JSONResponse(result)
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=400)
|
||||
|
|
|
|||
|
|
@ -76,13 +76,14 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
|
|||
tag_mode=sess.get("tag_mode", "AND"),
|
||||
use_owned_only=use_owned,
|
||||
prefer_owned=prefer,
|
||||
owned_names=owned_names_list,
|
||||
owned_names=owned_names_list,
|
||||
locks=list(sess.get("locks", [])),
|
||||
custom_export_base=sess.get("custom_export_base"),
|
||||
multi_copy=sess.get("multi_copy"),
|
||||
prefer_combos=bool(sess.get("prefer_combos")),
|
||||
combo_target_count=int(sess.get("combo_target_count", 2)),
|
||||
combo_balance=str(sess.get("combo_balance", "mix")),
|
||||
exclude_cards=sess.get("exclude_cards"),
|
||||
)
|
||||
if set_on_session:
|
||||
sess["build_ctx"] = ctx
|
||||
|
|
|
|||
|
|
@ -1377,6 +1377,7 @@ def start_build_ctx(
|
|||
prefer_combos: bool | None = None,
|
||||
combo_target_count: int | None = None,
|
||||
combo_balance: str | None = None,
|
||||
exclude_cards: List[str] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
logs: List[str] = []
|
||||
|
||||
|
|
@ -1449,6 +1450,19 @@ def start_build_ctx(
|
|||
b.setup_dataframes()
|
||||
# Apply the same global pool pruning in interactive builds for consistency
|
||||
_global_prune_disallowed_pool(b)
|
||||
|
||||
# Apply exclude cards (M0.5: Phase 1 - Exclude Only)
|
||||
try:
|
||||
if exclude_cards:
|
||||
b.exclude_cards = list(exclude_cards)
|
||||
# The filtering is already applied in setup_dataframes(), but we need
|
||||
# to call it again after setting exclude_cards
|
||||
b._combined_cards_df = None # Clear cache to force rebuild
|
||||
b.setup_dataframes() # This will now apply the exclude filtering
|
||||
out(f"Applied exclude filtering for {len(exclude_cards)} patterns")
|
||||
except Exception as e:
|
||||
out(f"Failed to apply exclude cards: {e}")
|
||||
|
||||
# Thread multi-copy selection onto builder for stage generation/runner
|
||||
try:
|
||||
b._web_multi_copy = (multi_copy or None)
|
||||
|
|
|
|||
|
|
@ -91,6 +91,28 @@
|
|||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if allow_must_haves %}
|
||||
<div style="margin-top:1rem;">
|
||||
<label style="display:block;">
|
||||
<span class="muted">Cards to exclude (one per line)</span>
|
||||
<textarea name="exclude_cards" id="exclude_cards_textarea" placeholder="Sol Ring Rhystic Study Smothering Tithe"
|
||||
style="width:100%; min-height:60px; resize:vertical; font-family:monospace; font-size:12px;"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false">{{ form.exclude_cards if form and form.exclude_cards else '' }}</textarea>
|
||||
</label>
|
||||
<div style="display:flex; align-items:center; gap:.5rem; margin-top:.5rem;">
|
||||
<label for="exclude_file_upload" class="btn" style="cursor:pointer; font-size:12px; padding:.25rem .5rem;">
|
||||
📄 Upload .txt file
|
||||
</label>
|
||||
<input type="file" id="exclude_file_upload" accept=".txt" style="display:none;"
|
||||
onchange="handleExcludeFileUpload(this)" />
|
||||
<small class="muted">or enter cards manually above</small>
|
||||
</div>
|
||||
<small class="muted" style="display:block; margin-top:.25rem;">
|
||||
Enter one card name per line. Names will be fuzzy-matched against the card database.
|
||||
</small>
|
||||
<div id="exclude_validation" style="margin-top:.5rem; font-size:12px;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</details>
|
||||
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:1rem;">
|
||||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
|
||||
|
|
@ -101,8 +123,122 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
// Handle exclude cards file upload
|
||||
function handleExcludeFileUpload(input) {
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
if (!file.name.toLowerCase().endsWith('.txt')) {
|
||||
alert('Please select a .txt file');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const textarea = document.getElementById('exclude_cards_textarea');
|
||||
const fileContent = e.target.result;
|
||||
const newlineRegex = /\r?\n/;
|
||||
const lines = fileContent.split(newlineRegex).map(function(line) { return line.trim(); }).filter(function(line) { return line; });
|
||||
|
||||
// Merge with existing content (if any)
|
||||
const existingContent = textarea.value.trim();
|
||||
const existingLines = existingContent ? existingContent.split(newlineRegex).map(function(line) { return line.trim(); }).filter(function(line) { return line; }) : [];
|
||||
|
||||
// Combine and deduplicate
|
||||
const allLinesSet = new Set([].concat(existingLines).concat(lines));
|
||||
const allLines = Array.from(allLinesSet);
|
||||
textarea.value = allLines.join('\n');
|
||||
|
||||
// Show feedback
|
||||
const validation = document.getElementById('exclude_validation');
|
||||
if (validation) {
|
||||
validation.innerHTML = '<span style="color: #4ade80;">✓ Loaded ' + lines.length + ' cards from file</span>';
|
||||
setTimeout(function() { validation.innerHTML = ''; }, 3000);
|
||||
}
|
||||
|
||||
// Clear file input for re-upload
|
||||
input.value = '';
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Live validation for exclude cards
|
||||
function validateExcludeCards() {
|
||||
const textarea = document.getElementById('exclude_cards_textarea');
|
||||
const validation = document.getElementById('exclude_validation');
|
||||
|
||||
if (!textarea || !validation) return;
|
||||
|
||||
const content = textarea.value.trim();
|
||||
|
||||
if (!content) {
|
||||
validation.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
validation.innerHTML = '<span style="color: #6b7280;">Validating...</span>';
|
||||
|
||||
// Use fetch instead of HTMX for this simple case
|
||||
const formData = new FormData();
|
||||
formData.append('exclude_cards', content);
|
||||
|
||||
fetch('/build/validate/exclude_cards', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
validation.innerHTML = '<span style="color: #ef4444;">Error: ' + data.error + '</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
const count = data.count || 0;
|
||||
const limit = data.limit || 15;
|
||||
|
||||
if (count === 0) {
|
||||
validation.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Count display
|
||||
const countColor = data.over_limit ? '#ef4444' : (count > limit * 0.8 ? '#f59e0b' : '#4ade80');
|
||||
html += '<span style="color: ' + countColor + ';">📊 ' + count + '/' + limit + ' cards</span>';
|
||||
|
||||
// Warnings
|
||||
if (data.warnings && data.warnings.length > 0) {
|
||||
html += ' <span style="color: #ef4444;">⚠ ' + data.warnings[0] + '</span>';
|
||||
}
|
||||
|
||||
validation.innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
validation.innerHTML = '<span style="color: #ef4444;">Validation failed</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// Set up live validation on textarea changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const textarea = document.getElementById('exclude_cards_textarea');
|
||||
if (textarea) {
|
||||
let validationTimer;
|
||||
textarea.addEventListener('input', function() {
|
||||
clearTimeout(validationTimer);
|
||||
validationTimer = setTimeout(validateExcludeCards, 500); // Debounce 500ms
|
||||
});
|
||||
|
||||
// Initial validation if there's content
|
||||
if (textarea.value.trim()) {
|
||||
validateExcludeCards();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Auto deck name generation on commander change
|
||||
(function(){
|
||||
// Backdrop click to close
|
||||
try{
|
||||
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
|
||||
var backdrop = modal ? modal.querySelector('.modal-backdrop') : null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue