diff --git a/cards/templates/cards/_bulk_import_preview.html b/cards/templates/cards/_bulk_import_preview.html
index 7210d6e..c89d6a7 100644
--- a/cards/templates/cards/_bulk_import_preview.html
+++ b/cards/templates/cards/_bulk_import_preview.html
@@ -1,30 +1,100 @@
{% comment %} Preview fragment for pasted bulk import data. {% endcomment %}
+ {% if prev_pk %}
+
← Prev
+ {% else %}
+
+ {% endif %}
+ {% if next_pk %}
+
Next →
+ {% else %}
+
+ {% endif %}
1
2
3
diff --git a/cards/views.py b/cards/views.py
index 47d5591..04438dc 100644
--- a/cards/views.py
+++ b/cards/views.py
@@ -4,6 +4,9 @@ from django.template.loader import render_to_string
import io
import zipfile
import os
+import json
+import csv
+from io import StringIO
from .models import Dinosaur, BackgroundImage
from .forms import DinosaurBulkFormSet
from .forms import DinosaurForm
@@ -288,7 +291,21 @@ def preview_card(request, pk):
design = 1
template_name = f'cards/_card_preview_design{design}.html'
template_name = f'cards/_card_preview_design{design}.html'
- context = {'card': card, 'background': background, 'design': design, 'fragment_template': template_name}
+ # Compute previous/next PKs using a stable ordering (by name)
+ qs = list(Dinosaur.objects.order_by('name').values_list('pk', flat=True))
+ prev_pk = None
+ next_pk = None
+ try:
+ idx = qs.index(card.pk)
+ if idx > 0:
+ prev_pk = qs[idx - 1]
+ if idx < len(qs) - 1:
+ next_pk = qs[idx + 1]
+ except ValueError:
+ prev_pk = None
+ next_pk = None
+
+ context = {'card': card, 'background': background, 'design': design, 'fragment_template': template_name, 'prev_pk': prev_pk, 'next_pk': next_pk}
if is_htmx(request):
return render(request, template_name, context)
# For non-HTMX full page preview, render the generic preview which can accept design too
@@ -438,6 +455,8 @@ def import_from_paste(request):
entry = {k.lower(): v for k, v in item.items()} if isinstance(item, dict) else {'raw': str(item)}
s = {
'pk': entry.get('pk') or entry.get('id'),
+ 'era': entry.get('era'),
+ 'diet': entry.get('diet'),
'name': entry.get('name'),
'speed_kmh': entry.get('speed_kmh') or entry.get('speed') or entry.get('speed_km/h'),
'weight_kg': entry.get('weight_kg') or entry.get('weight') or entry.get('mass_kg'),
@@ -454,9 +473,106 @@ def import_from_paste(request):
s['facts'] = parts
suggestions.append(s)
- # provide a JSON-serialized version of suggestions so the preview form can post it
+ # Build contextual preview: match suggestions to existing dinosaurs and compute field-level diffs
+ from .models import Dinosaur
+
+ def _map_choice(val, choices):
+ """Map an incoming value (code or display) to the internal choice code.
+ Returns the code or None if not found."""
+ if val is None:
+ return None
+ s = str(val).strip()
+ if not s:
+ return None
+ s_low = s.lower()
+ # Try exact code match first
+ for code, label in choices:
+ if s_low == str(code).lower() or s_low == str(label).lower():
+ return code
+ return None
+
+ preview_items = []
+ for idx, s in enumerate(suggestions):
+ pk = s.get('pk')
+ name = s.get('name')
+ matched = None
+ if pk:
+ try:
+ matched = Dinosaur.objects.get(pk=pk)
+ except Dinosaur.DoesNotExist:
+ matched = None
+ if matched is None and name:
+ try:
+ matched = Dinosaur.objects.get(name__iexact=name)
+ except Dinosaur.DoesNotExist:
+ matched = None
+
+ # normalize proposed values for comparison
+ def _to_float(v):
+ try:
+ return float(v)
+ except Exception:
+ return None
+
+ proposed = {
+ 'name': s.get('name'),
+ 'era': _map_choice(s.get('era'), Dinosaur.ERA_CHOICES),
+ 'diet': _map_choice(s.get('diet'), Dinosaur.DIET_CHOICES),
+ 'speed_kmh': _to_float(s.get('speed_kmh')),
+ 'weight_kg': _to_float(s.get('weight_kg')),
+ 'height_m': _to_float(s.get('height_m')),
+ 'intelligence_score': _to_float(s.get('intelligence_score')),
+ 'facts': s.get('facts')
+ }
+
+ existing = None
+ diffs = []
+ if matched:
+ existing = {
+ 'name': matched.name,
+ 'era': matched.era,
+ 'diet': matched.diet,
+ 'speed_kmh': float(matched.speed) if matched.speed is not None else None,
+ 'weight_kg': float(matched.weight) if matched.weight is not None else None,
+ 'height_m': float(matched.height) if matched.height is not None else None,
+ 'intelligence_score': float(matched.intelligence) if matched.intelligence is not None else None,
+ 'facts': matched.facts_list
+ }
+ # compare field-by-field (tolerant numeric comparison)
+ for field in ['name', 'era', 'diet']:
+ if proposed.get(field) is not None and str(proposed.get(field)) != str(existing.get(field)):
+ diffs.append(field)
+ for field in ['speed_kmh', 'weight_kg', 'height_m', 'intelligence_score']:
+ pv = proposed.get(field)
+ ev = existing.get(field)
+ if pv is not None and ev is not None:
+ try:
+ if abs(float(pv) - float(ev)) > 0.0001:
+ diffs.append(field)
+ except Exception:
+ diffs.append(field)
+ elif pv is not None and ev is None:
+ diffs.append(field)
+ # facts: compare list vs list/string
+ prop_facts = proposed.get('facts')
+ if isinstance(prop_facts, str):
+ prop_list = [l.strip() for l in prop_facts.splitlines() if l.strip()]
+ elif isinstance(prop_facts, list):
+ prop_list = [str(x).strip() for x in prop_facts if str(x).strip()]
+ else:
+ prop_list = []
+ if prop_list and prop_list != existing.get('facts'):
+ diffs.append('facts')
+
+ else:
+ # New record: everything proposed counts as a diff
+ existing = None
+ diffs = [k for k, v in proposed.items() if v is not None and v != ""]
+
+ preview_items.append({'index': idx, 'suggestion': s, 'matched': matched, 'existing': existing, 'proposed': proposed, 'diffs': diffs})
+
suggestions_json = json.dumps(suggestions, ensure_ascii=False)
- return render(request, 'cards/_bulk_import_preview.html', {'suggestions': suggestions, 'suggestions_json': suggestions_json})
+ return render(request, 'cards/_bulk_import_preview.html', {'preview_items': preview_items, 'suggestions_json': suggestions_json})
def apply_bulk_import(request):
@@ -473,7 +589,13 @@ def apply_bulk_import(request):
return HttpResponseBadRequest('Failed to parse JSON data')
applied = 0
- for item in parsed:
+ def _to_float(v):
+ try:
+ return float(v)
+ except Exception:
+ return None
+
+ for idx, item in enumerate(parsed):
name = item.get('name') if isinstance(item, dict) else None
pk = item.get('pk') if isinstance(item, dict) else None
d = None
@@ -487,34 +609,77 @@ def apply_bulk_import(request):
d = Dinosaur.objects.get(name__iexact=name)
except Dinosaur.DoesNotExist:
d = None
+ # If no existing dinosaur matched, allow creation when user checked the create box
+ create_requested = bool(request.POST.get(f'apply-{idx}-create'))
+ if not d and create_requested:
+ d = Dinosaur()
+ is_new = True
if not d:
continue
- # apply numeric fields if present
- def _to_float(v):
- try:
- return float(v)
- except Exception:
- return None
+
+ # determine whether the user checked to apply this field for this item
+ def _should_apply(field_name):
+ return bool(request.POST.get(f'apply-{idx}-{field_name}'))
+
speed = _to_float(item.get('speed_kmh'))
weight = _to_float(item.get('weight_kg'))
height = _to_float(item.get('height_m'))
intelligence = _to_float(item.get('intelligence_score') or item.get('intelligence'))
- if speed is not None:
+
+ if speed is not None and _should_apply('speed_kmh'):
d.speed = int(round(speed))
- if weight is not None:
+ if weight is not None and _should_apply('weight_kg'):
d.weight = int(round(weight))
- if height is not None:
+ if height is not None and _should_apply('height_m'):
d.height = Decimal(str(round(height, 1)))
- if intelligence is not None:
+ if intelligence is not None and _should_apply('intelligence_score'):
if 0 <= intelligence <= 5:
d.intelligence = int(round((intelligence / 5.0) * 100))
else:
d.intelligence = int(round(max(0, min(100, intelligence))))
- facts = item.get('facts')
- if isinstance(facts, list):
- d.facts = '\n'.join([str(x).strip() for x in facts if str(x).strip()])
- elif isinstance(facts, str):
- d.facts = '\n'.join([l.strip() for l in facts.splitlines() if l.strip()])
+
+ # Name
+ if item.get('name') and _should_apply('name'):
+ d.name = item.get('name')
+ # Ensure new records have a name (fallback)
+ if getattr(d, 'name', None) in (None, ''):
+ d.name = item.get('name') or f'Imported Dinosaur {idx + 1}'
+
+ # Era and diet: accept code or display label
+ if item.get('era') and _should_apply('era'):
+ era_val = item.get('era')
+ if isinstance(era_val, str):
+ mapped = None
+ for code, label in Dinosaur.ERA_CHOICES:
+ if era_val.lower() == code.lower() or era_val.lower() == label.lower():
+ mapped = code
+ break
+ if mapped:
+ d.era = mapped
+ else:
+ d.era = era_val
+
+ if item.get('diet') and _should_apply('diet'):
+ diet_val = item.get('diet')
+ if isinstance(diet_val, str):
+ mapped = None
+ for code, label in Dinosaur.DIET_CHOICES:
+ if diet_val.lower() == code.lower() or diet_val.lower() == label.lower():
+ mapped = code
+ break
+ if mapped:
+ d.diet = mapped
+ else:
+ d.diet = diet_val
+
+ # Facts
+ if _should_apply('facts'):
+ facts = item.get('facts')
+ if isinstance(facts, list):
+ d.facts = '\n'.join([str(x).strip() for x in facts if str(x).strip()])
+ elif isinstance(facts, str):
+ d.facts = '\n'.join([l.strip() for l in facts.splitlines() if l.strip()])
+
d.save()
applied += 1
diff --git a/db.sqlite3 b/db.sqlite3
index ae16086..2707ba9 100644
Binary files a/db.sqlite3 and b/db.sqlite3 differ