Compare commits

...

3 Commits

8 changed files with 447 additions and 116 deletions

View File

@@ -0,0 +1,6 @@
Implemented CSV export for generated packs and cards.
Implemented CSV copy to clipboard functionality.
Implemented CSV import template download.
Removed demo button and functionality from CubeManager.
Updated CSV import template content.
Refactored parsing logic to support complex CSV imports.

View File

@@ -7,8 +7,32 @@ import { DeckTester } from './modules/tester/DeckTester';
import { Pack } from './services/PackGeneratorService';
export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>('draft');
const [generatedPacks, setGeneratedPacks] = useState<Pack[]>([]);
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>(() => {
const saved = localStorage.getItem('activeTab');
return (saved as 'draft' | 'bracket' | 'lobby' | 'tester') || 'draft';
});
const [generatedPacks, setGeneratedPacks] = useState<Pack[]>(() => {
try {
const saved = localStorage.getItem('generatedPacks');
return saved ? JSON.parse(saved) : [];
} catch (e) {
console.error("Failed to load packs from storage", e);
return [];
}
});
React.useEffect(() => {
localStorage.setItem('activeTab', activeTab);
}, [activeTab]);
React.useEffect(() => {
try {
localStorage.setItem('generatedPacks', JSON.stringify(generatedPacks));
} catch (e) {
console.error("Failed to save packs to storage", e);
}
}, [generatedPacks]);
return (
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans pb-20">

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { DraftCard, Pack } from '../services/PackGeneratorService';
import { Copy } from 'lucide-react';
import { StackView } from './StackView';
@@ -8,7 +8,87 @@ interface PackCardProps {
viewMode: 'list' | 'grid' | 'stack';
}
// --- Floating Preview Component ---
const FloatingPreview: React.FC<{ card: DraftCard; x: number; y: number }> = ({ card, x, y }) => {
const isFoil = card.finish === 'foil';
const imgRef = useRef<HTMLImageElement>(null);
// Basic boundary detection to prevent going off-screen
// We check window dimensions. This might need customization based on the actual viewport,
// but window is a good safe default.
const [adjustedPos, setAdjustedPos] = useState({ top: y, left: x });
useEffect(() => {
// Offset from cursor
const OFFSET = 20;
const CARD_WIDTH = 300; // Approx width of preview
const CARD_HEIGHT = 420; // Approx height of preview
let newX = x + OFFSET;
let newY = y + OFFSET;
// Flip horizontally if too close to right edge
if (newX + CARD_WIDTH > window.innerWidth) {
newX = x - CARD_WIDTH - OFFSET;
}
// Flip vertically if too close to bottom edge
if (newY + CARD_HEIGHT > window.innerHeight) {
newY = y - CARD_HEIGHT - OFFSET;
}
setAdjustedPos({ top: newY, left: newX });
}, [x, y]);
return (
<div
className="fixed z-[9999] pointer-events-none transition-opacity duration-75"
style={{
top: adjustedPos.top,
left: adjustedPos.left
}}
>
<div className="relative w-[300px] rounded-xl overflow-hidden shadow-2xl border-4 border-slate-900 bg-black">
<img ref={imgRef} src={card.image} alt={card.name} className="w-full h-auto" />
{isFoil && <div className="absolute inset-0 bg-gradient-to-br from-purple-500/20 to-blue-500/20 mix-blend-overlay animate-pulse"></div>}
</div>
</div>
);
};
// --- Hover Wrapper to handle mouse events ---
const CardHoverWrapper: React.FC<{ card: DraftCard; children: React.ReactNode; className?: string }> = ({ card, children, className }) => {
const [isHovering, setIsHovering] = useState(false);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
// Only show preview if there is an image
const hasImage = !!card.image;
const handleMouseMove = (e: React.MouseEvent) => {
if (!hasImage) return;
setMousePos({ x: e.clientX, y: e.clientY });
};
return (
<div
className={className}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onMouseMove={handleMouseMove}
>
{children}
{isHovering && hasImage && (
<FloatingPreview card={card} x={mousePos.x} y={mousePos.y} />
)}
</div>
);
};
const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
const isFoil = (card: DraftCard) => card.finish === 'foil';
const getRarityColorClass = (rarity: string) => {
switch (rarity) {
case 'common': return 'bg-black text-white border-slate-600';
@@ -20,21 +100,19 @@ const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
};
return (
<li className="relative group">
<div className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700/50 cursor-pointer">
<span className={`font-medium ${card.rarity === 'mythic' ? 'text-orange-400' : card.rarity === 'rare' ? 'text-yellow-400' : card.rarity === 'uncommon' ? 'text-slate-200' : 'text-slate-400'}`}>
<CardHoverWrapper card={card} className="relative group">
<div className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700/50 cursor-pointer transition-colors">
<span className={`font-medium flex items-center gap-2 ${card.rarity === 'mythic' ? 'text-orange-400' : card.rarity === 'rare' ? 'text-yellow-400' : card.rarity === 'uncommon' ? 'text-slate-200' : 'text-slate-400'}`}>
{card.name}
{isFoil(card) && (
<span className="text-transparent bg-clip-text bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 animate-pulse text-xs font-bold border border-purple-500/50 rounded px-1">
FOIL
</span>
)}
</span>
<span className={`w-2 h-2 rounded-full border ${getRarityColorClass(card.rarity)} !p-0 !text-[0px]`}></span>
</div>
{card.image && (
<div className="hidden group-hover:block absolute left-0 top-full z-50 mt-1 pointer-events-none">
<div className="bg-black p-1 rounded-lg border border-slate-500 shadow-2xl w-48">
<img src={card.image} alt={card.name} className="w-full rounded" />
</div>
</div>
)}
</li>
</CardHoverWrapper>
);
};
@@ -44,10 +122,11 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
const uncommons = pack.cards.filter(c => c.rarity === 'uncommon');
const commons = pack.cards.filter(c => c.rarity === 'common');
const isFoil = (card: DraftCard) => card.finish === 'foil';
const copyPackToClipboard = () => {
const text = pack.cards.map(c => c.name).join('\n');
navigator.clipboard.writeText(text);
// Toast notification could go here
alert(`Pack list ${pack.id} copied!`);
};
@@ -95,20 +174,29 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
{viewMode === 'grid' && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{pack.cards.map((card) => (
<div key={card.id} className="relative aspect-[2.5/3.5] bg-slate-900 rounded-lg overflow-hidden group hover:scale-105 transition-transform duration-200 shadow-xl border border-slate-800">
{card.image ? (
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">
{card.name}
<CardHoverWrapper key={card.id} card={card}>
<div className="relative group bg-slate-900 rounded-lg">
{/* Visual Card */}
<div className={`relative aspect-[2.5/3.5] overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 cursor-pointer ${isFoil(card) ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
{isFoil(card) && <div className="absolute inset-0 z-20 bg-gradient-to-tr from-purple-500/10 via-transparent to-pink-500/10 mix-blend-color-dodge pointer-events-none" />}
{isFoil(card) && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1 rounded backdrop-blur-sm">FOIL</div>}
{card.image ? (
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">
{card.name}
</div>
)}
{/* Rarity Stripe */}
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' :
card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' :
'bg-black'
}`} />
</div>
)}
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' :
card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' :
'bg-black'
}`} />
</div>
</div>
</CardHoverWrapper>
))}
</div>
)}

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users } from 'lucide-react';
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown, Trash2 } from 'lucide-react';
import { CardParserService } from '../../services/CardParserService';
import { ScryfallService, ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
@@ -20,32 +20,91 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
const generatorService = React.useMemo(() => new PackGeneratorService(), []);
// --- State ---
const [inputText, setInputText] = useState('');
const [inputText, setInputText] = useState(() => localStorage.getItem('cube_inputText') || '');
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState('');
const [copySuccess, setCopySuccess] = useState(false);
const [rawScryfallData, setRawScryfallData] = useState<ScryfallCard[] | null>(null);
const [rawScryfallData, setRawScryfallData] = useState<ScryfallCard[] | null>(() => {
try {
const saved = localStorage.getItem('cube_rawScryfallData');
return saved ? JSON.parse(saved) : null;
} catch (e) {
console.warn("Failed to load rawScryfallData from local storage", e);
return null;
}
});
useEffect(() => {
try {
if (rawScryfallData) {
localStorage.setItem('cube_rawScryfallData', JSON.stringify(rawScryfallData));
} else {
localStorage.removeItem('cube_rawScryfallData');
}
} catch (e) {
console.warn("Failed to save rawScryfallData to local storage (likely quota exceeded)", e);
}
}, [rawScryfallData]);
const [processedData, setProcessedData] = useState<{ pools: ProcessedPools, sets: SetsMap } | null>(null);
const [filters, setFilters] = useState({
ignoreBasicLands: true,
ignoreCommander: true,
ignoreTokens: true
const [filters, setFilters] = useState<{
ignoreBasicLands: boolean;
ignoreCommander: boolean;
ignoreTokens: boolean;
}>(() => {
try {
const saved = localStorage.getItem('cube_filters');
return saved ? JSON.parse(saved) : {
ignoreBasicLands: true,
ignoreCommander: true,
ignoreTokens: true
};
} catch {
return {
ignoreBasicLands: true,
ignoreCommander: true,
ignoreTokens: true
};
}
});
// UI State
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('list');
// Generation Settings
const [genSettings, setGenSettings] = useState<PackGenerationSettings>({
mode: 'mixed',
rarityMode: 'peasant'
const [genSettings, setGenSettings] = useState<PackGenerationSettings>(() => {
try {
const saved = localStorage.getItem('cube_genSettings');
return saved ? JSON.parse(saved) : {
mode: 'mixed',
rarityMode: 'peasant'
};
} catch {
return {
mode: 'mixed',
rarityMode: 'peasant'
};
}
});
const [sourceMode, setSourceMode] = useState<'upload' | 'set'>('upload');
const [sourceMode, setSourceMode] = useState<'upload' | 'set'>(() =>
(localStorage.getItem('cube_sourceMode') as 'upload' | 'set') || 'upload'
);
const [availableSets, setAvailableSets] = useState<ScryfallSet[]>([]);
const [selectedSet, setSelectedSet] = useState<string>('');
const [numBoxes, setNumBoxes] = useState<number>(3);
const [selectedSet, setSelectedSet] = useState(() => localStorage.getItem('cube_selectedSet') || '');
const [numBoxes, setNumBoxes] = useState<number>(() => {
const saved = localStorage.getItem('cube_numBoxes');
return saved ? parseInt(saved) : 3;
});
// --- Persistence Effects ---
useEffect(() => localStorage.setItem('cube_inputText', inputText), [inputText]);
useEffect(() => localStorage.setItem('cube_filters', JSON.stringify(filters)), [filters]);
useEffect(() => localStorage.setItem('cube_genSettings', JSON.stringify(genSettings)), [genSettings]);
useEffect(() => localStorage.setItem('cube_sourceMode', sourceMode), [sourceMode]);
useEffect(() => localStorage.setItem('cube_selectedSet', selectedSet), [selectedSet]);
useEffect(() => localStorage.setItem('cube_numBoxes', numBoxes.toString()), [numBoxes]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -73,36 +132,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
event.target.value = '';
};
const loadDemoData = () => {
const demo = `20 Shock
20 Llanowar Elves
20 Giant Growth
20 Counterspell
20 Dark Ritual
20 Lightning Bolt
20 Opt
20 Consider
20 Ponder
20 Preordain
20 Brainstorm
20 Duress
20 Faithless Looting
20 Thrill of Possibility
20 Terror
10 Serra Angel
10 Vampire Nighthawk
10 Eternal Witness
10 Mulldrifter
10 Flametongue Kavu
5 Wrath of God
5 Birds of Paradise
2 Jace, the Mind Sculptor
1 Sheoldred, the Apocalypse
20 Island
1 Sol Ring
1 Command Tower`;
setInputText(demo);
};
const fetchAndParse = async () => {
setLoading(true);
@@ -129,7 +159,14 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
identifiers.forEach(id => {
const card = scryfallService.getCachedCard(id.type === 'id' ? { id: id.value } : { name: id.value });
if (card) {
for (let i = 0; i < id.quantity; i++) expandedCards.push(card);
for (let i = 0; i < id.quantity; i++) {
// Clone card to attach unique properties like finish
const expandedCard = { ...card };
if (id.finish) {
expandedCard.finish = id.finish;
}
expandedCards.push(expandedCard);
}
}
});
}
@@ -175,10 +212,82 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
}, 50);
};
const handleExportCsv = () => {
if (packs.length === 0) return;
const csvContent = generatorService.generateCsv(packs);
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `generated_packs_${new Date().toISOString().slice(0, 10)}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleDownloadTemplate = () => {
const template = `Quantity,Name,Finish,Edition Name,Scryfall ID
5,Agate Assault,Normal,Bloomburrow,7dd9946b-515e-4e0d-9da2-711e126e9fa6
1,Agate-Blade Assassin,Normal,Bloomburrow,39ebb84a-1c52-4b07-9bd0-b360523b3a5b
4,Agate-Blade Assassin,Normal,Bloomburrow,39ebb84a-1c52-4b07-9bd0-b360523b3a5b
4,Alania's Pathmaker,Normal,Bloomburrow,d3871fe6-e26e-4ab4-bd81-7e3c7b8135c1
1,Artist's Talent,Normal,Bloomburrow,8b9e51d9-189b-4dd6-87cb-628ea6373e81
1,Azure Beastbinder,Normal,Bloomburrow,211af1bf-910b-41a5-b928-f378188d1871
3,Bakersbane Duo,Normal,Bloomburrow,5309354f-1ff4-4fa9-9141-01ea2f7588ab
2,Bandit's Talent,Normal,Bloomburrow,485dc8d8-9e44-4a0f-9ff6-fa448e232290
3,Banishing Light,Normal,Bloomburrow,25a06f82-ebdb-4dd6-bfe8-958018ce557c
4,Barkform Harvester,Normal,Bloomburrow,f77049a6-0f22-415b-bc89-20bcb32accf6
1,Bark-Knuckle Boxer,Normal,Bloomburrow,582637a9-6aa0-4824-bed7-d5fc91bda35e
1,"Baylen, the Haymaker",Normal,Bloomburrow,00e93be2-e06b-4774-8ba5-ccf82a6da1d8
3,Bellowing Crier,Normal,Bloomburrow,ca2215dd-6300-49cf-b9b2-3a840b786c31
1,Blacksmith's Talent,Normal,Bloomburrow,4bb318fa-481d-40a7-978e-f01b49101ae0
1,Blooming Blast,Normal,Bloomburrow,0cd92a83-cec3-4085-a929-3f204e3e0140
4,Bonebind Orator,Normal,Bloomburrow,faf226fa-ca09-4468-8804-87b2a7de2c66
2,Bonecache Overseer,Normal,Bloomburrow,82defb87-237f-4b77-9673-5bf00607148f
1,Brambleguard Captain,Foil,Bloomburrow,e200b8bf-f2f3-4157-8e04-02baf07a963e`;
const blob = new Blob([template], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `import_template.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleCopyCsv = async () => {
if (packs.length === 0) return;
const csvContent = generatorService.generateCsv(packs);
try {
await navigator.clipboard.writeText(csvContent);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error('Failed to copy: ', err);
alert('Failed to copy CSV to clipboard');
}
};
const toggleFilter = (key: keyof typeof filters) => {
setFilters(prev => ({ ...prev, [key]: !prev[key] }));
};
const handleReset = () => {
if (window.confirm("Are you sure you want to clear this session? All parsed cards and generated packs will be lost.")) {
setPacks([]);
setInputText('');
setRawScryfallData(null);
setProcessedData(null);
setSelectedSet('');
localStorage.removeItem('cube_inputText');
localStorage.removeItem('cube_rawScryfallData');
localStorage.removeItem('cube_selectedSet');
// We keep filters and settings as they are user preferences
}
};
return (
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 p-4 md:p-6">
@@ -218,7 +327,9 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
<Upload className="w-3 h-3" /> Upload
</button>
<input type="file" ref={fileInputRef} className="hidden" accept=".csv,.txt" onChange={handleFileUpload} />
<button onClick={loadDemoData} className="text-xs text-purple-400 hover:text-purple-300 hover:underline">Demo</button>
<button onClick={handleDownloadTemplate} className="text-xs text-emerald-400 hover:text-emerald-300 flex items-center gap-1 hover:underline">
<FileDown className="w-3 h-3" /> Template
</button>
</div>
</div>
@@ -369,6 +480,15 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <RotateCcw className="w-5 h-5" />}
{loading ? 'Generating...' : '2. Generate Packs'}
</button>
{/* Reset Button */}
<button
onClick={handleReset}
className="w-full mt-4 py-2 text-xs font-semibold text-slate-500 hover:text-red-400 hover:bg-red-900/10 rounded-lg transition-colors flex items-center justify-center gap-2"
title="Clear all data and start over"
>
<Trash2 className="w-3 h-3" /> Clear Session
</button>
</div>
</div>
@@ -385,12 +505,29 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
<div className="flex gap-2">
{/* Play Button */}
{packs.length > 0 && (
<button
onClick={onGoToLobby}
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
>
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span>
</button>
<>
<button
onClick={onGoToLobby}
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
>
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span>
</button>
<button
onClick={handleExportCsv}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
title="Export as CSV"
>
<Download className="w-4 h-4" /> <span className="hidden sm:inline">Export</span>
</button>
<button
onClick={handleCopyCsv}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
title="Copy CSV to Clipboard"
>
{copySuccess ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
<span className="hidden sm:inline">{copySuccess ? 'Copied!' : 'Copy'}</span>
</button>
</>
)}
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700">

View File

@@ -11,11 +11,22 @@ interface LobbyManagerProps {
export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) => {
const [activeRoom, setActiveRoom] = useState<any>(null);
const [playerName, setPlayerName] = useState('');
const [playerName, setPlayerName] = useState(() => localStorage.getItem('player_name') || '');
const [joinRoomId, setJoinRoomId] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [playerId] = useState(() => Math.random().toString(36).substring(2) + Date.now().toString(36)); // Simple persistent ID
const [playerId] = useState(() => {
const saved = localStorage.getItem('player_id');
if (saved) return saved;
const newId = Math.random().toString(36).substring(2) + Date.now().toString(36);
localStorage.setItem('player_id', newId);
return newId;
});
// Persist player name
React.useEffect(() => {
localStorage.setItem('player_name', playerName);
}, [playerName]);
const connect = () => {
if (!socketService.socket.connected) {

View File

@@ -2,6 +2,7 @@ export interface CardIdentifier {
type: 'id' | 'name';
value: string;
quantity: number;
finish?: 'foil' | 'normal';
}
export class CardParserService {
@@ -11,54 +12,102 @@ export class CardParserService {
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
lines.forEach(line => {
if (line.toLowerCase().startsWith('quantity') || line.toLowerCase().startsWith('count,name')) return;
// Skip header
if (line.toLowerCase().startsWith('quantity') && line.toLowerCase().includes('name')) return;
const idMatch = line.match(uuidRegex);
const cleanLineForQty = line.replace(/['"]/g, '');
const quantityMatch = cleanLineForQty.match(/^(\d+)[xX\s,;]/);
const quantity = quantityMatch ? parseInt(quantityMatch[1], 10) : 1;
let identifier: { type: 'id' | 'name', value: string } | null = null;
if (idMatch) {
identifier = { type: 'id', value: idMatch[0] };
} else {
const cleanLine = line.replace(/['"]/g, '');
// Remove leading quantity
let name = cleanLine.replace(/^(\d+)[xX\s,;]+/, '').trim();
// Extract quantity if present before ID, otherwise default to 1
// Simple check: Look for "Nx ID" or "N, ID" pattern?
// The previous/standard logic usually treats ID lines as 1x unless specified.
// Let's try to find a quantity at the start if it exists differently from UUID.
// But usually UUID lines are direct from export.
// Remove set codes in parentheses/brackets e.g. (M20), [STA]
// This regex looks for ( starts, anything inside, ) ends, or same for []
name = name.replace(/\s*[\(\[].*?[\)\]]/g, '');
// But our CSV template puts ID at the end.
// If UUID is present anywhere in the line, we might trust it over the name.
// Let's stick to the previous logic: if UUID is found, use it.
// BUT, we should try to parse the whole CSV line if possible to get Finish and Quantity.
// Remove trailing collector numbers (digits at the very end)
name = name.replace(/\s+\d+$/, '');
// Let's parse with CSV logic first.
const parts = this.parseCsvLine(line);
if (parts.length >= 2) {
const qty = parseInt(parts[0]);
// If valid CSV structure
if (!isNaN(qty)) {
// const name = parts[1]; // We can keep name for reference, but we use ID if present
const finishRaw = parts[2]?.toLowerCase();
const finish = (finishRaw === 'foil' || finishRaw === 'etched') ? 'foil' : (finishRaw === 'normal' ? 'normal' : undefined);
// Remove trailing punctuation
name = name.replace(/^[,;]+|[,;]+$/g, '').trim();
// If the last part has UUID, use it.
const uuidPart = parts.find(p => uuidRegex.test(p));
if (uuidPart) {
const uuid = uuidPart.match(uuidRegex)![0];
rawCardList.push({ type: 'id', value: uuid, quantity: qty, finish });
return;
}
}
}
// If CSV like "Name, SetCode", take first part
if (name.includes(',')) name = name.split(',')[0].trim();
if (name && name.length > 1) identifier = { type: 'name', value: name };
// Fallback ID logic
rawCardList.push({ type: 'id', value: idMatch[0], quantity: 1 }); // Default simple UUID match
return;
}
if (identifier) {
// Return one entry per quantity? Or aggregated?
// The original code pushed multiple entries to an array.
// For a parser service, returning the count is better, but to match logic:
// "for (let i = 0; i < quantity; i++) rawCardList.push(identifier);"
// I will return one object with Quantity property to be efficient.
// Not an ID match, try parsing as name
const parts = this.parseCsvLine(line);
rawCardList.push({
type: identifier.type,
value: identifier.value,
quantity: quantity
});
if (parts.length >= 2 && !isNaN(parseInt(parts[0]))) {
// It looks like result of our CSV: Quantity, Name, Finish, ...
const quantity = parseInt(parts[0]);
const name = parts[1];
const finishRaw = parts[2]?.toLowerCase();
const finish = (finishRaw === 'foil' || finishRaw === 'etched') ? 'foil' : (finishRaw === 'normal' ? 'normal' : undefined);
if (name && name.length > 0) {
rawCardList.push({ type: 'name', value: name, quantity, finish });
return;
}
}
// Fallback to simple Arena/MTGO text format: "4 Lightning Bolt"
const cleanLine = line.replace(/['"]/g, '');
const simpleMatch = cleanLine.match(/^(\d+)[xX\s]+(.+)$/);
if (simpleMatch) {
let name = simpleMatch[2].trim();
// cleanup
name = name.replace(/\s*[\(\[].*?[\)\]]/g, ''); // remove set codes
name = name.replace(/\s+\d+$/, ''); // remove collector number
rawCardList.push({ type: 'name', value: name, quantity: parseInt(simpleMatch[1]) });
} else {
// Maybe just "Lightning Bolt" (1x)
let name = cleanLine.trim();
if (name) {
rawCardList.push({ type: 'name', value: name, quantity: 1 });
}
}
});
if (rawCardList.length === 0) throw new Error("No valid cards found.");
return rawCardList;
}
private parseCsvLine(line: string): string[] {
const parts: string[] = [];
let current = '';
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuote = !inQuote;
} else if (char === ',' && !inQuote) {
parts.push(current.trim().replace(/^"|"$/g, '')); // Parsing finished, strip outer quotes if just accumulated
current = '';
} else {
current += char;
}
}
parts.push(current.trim().replace(/^"|"$/g, ''));
return parts;
}
}

View File

@@ -10,6 +10,7 @@ export interface DraftCard {
set: string;
setCode: string;
setType: string;
finish?: 'foil' | 'normal';
}
export interface Pack {
@@ -71,7 +72,8 @@ export class PackGeneratorService {
image: cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || '',
set: cardData.set_name,
setCode: cardData.set,
setType: setType
setType: setType,
finish: cardData.finish
};
// Add to pools
@@ -309,4 +311,17 @@ export class PackGeneratorService {
return v.toString(16);
});
}
generateCsv(packs: Pack[]): string {
const header = "Pack ID,Name,Set Code,Rarity,Finish,Scryfall ID\n";
const rows = packs.flatMap(pack =>
pack.cards.map(card => {
const finish = card.finish || 'normal';
// Escape quotes in name if necessary
const safeName = card.name.includes(',') ? `"${card.name}"` : card.name;
return `${pack.id},${safeName},${card.setCode},${card.rarity},${finish},${card.scryfallId}`;
})
);
return header + rows.join('\n');
}
}

View File

@@ -10,6 +10,7 @@ export interface ScryfallCard {
colors?: string[];
image_uris?: { normal: string };
card_faces?: { image_uris: { normal: string } }[];
finish?: 'foil' | 'normal'; // Manual override from import
}
export class ScryfallService {