đď¸
1/**
2 * Universal deck list parser
3 *
4 * Handles multiple formats:
5 * - Arena: `4 Name (SET) 123`
6 * - TappedOut: `4x Name`
7 * - XMage: `4 [SET:123] Name`
8 * - MTGGoldfish: `4 Name [SET]` or `4 Name <variant> [SET]`
9 * - Moxfield: `4 Name (SET) 123 *F* #tag`
10 * - Archidekt: `4x Name (set) 123 [Section] ^Tag^`
11 * - Deckstats: `4 Name # !Commander`
12 */
13
14import { detectFormat } from "./detect";
15import { extractInlineSection, parseSectionMarker } from "./sections";
16import type {
17 DeckSection,
18 ParsedCardLine,
19 ParsedDeck,
20 ParseOptions,
21} from "./types";
22
23interface ParseCardLineOptions {
24 /** Original raw line to store in result (before any marker stripping) */
25 raw: string;
26 /** Format hint for format-specific marker handling */
27 format?: string;
28}
29
30/**
31 * Strip format-specific markers from a card line.
32 *
33 * Removes visual markers that don't affect card identity:
34 * - *F*, *A* (Moxfield foil/alter)
35 * - (F) at end (MTGGoldfish foil)
36 * - ^...^ (Archidekt color markers)
37 * - <...> (MTGGoldfish variant markers)
38 * - [...] (Archidekt category markers, unless XMage/MTGGoldfish format)
39 */
40export function stripMarkers(line: string, format?: string): string {
41 let result = line;
42
43 // Strip *F* (foil) and *A* (alter) markers (Moxfield style)
44 result = result.replace(/\s*\*[FA]\*\s*/g, " ");
45
46 // Strip (F) foil marker at end (MTGGoldfish style)
47 result = result.replace(/\s*\(F\)\s*$/i, "");
48
49 // Strip ^Tag,#color^ markers (Archidekt)
50 result = result.replace(/\s*\^[^^]+\^\s*/g, " ");
51
52 // Strip <variant> markers (MTGGoldfish)
53 result = result.replace(/<[^>]+>/g, " ");
54
55 // Strip [...] category markers (Archidekt) - but not for XMage/MTGGoldfish
56 // which use brackets for set codes
57 if (format !== "xmage" && format !== "mtggoldfish") {
58 result = result.replace(/\s*\[[^\]]+\]/g, "");
59 }
60
61 // Normalize whitespace
62 return result.replace(/\s+/g, " ").trim();
63}
64
65/**
66 * Parse a single line of card text.
67 *
68 * Handles all format variations for quantity, set code, and collector number.
69 * Tries patterns in order of specificity - most distinctive first.
70 */
71export function parseCardLine(
72 line: string,
73 options: ParseCardLineOptions,
74): ParsedCardLine | null {
75 const trimmedLine = line.trim();
76 if (!trimmedLine) {
77 return null;
78 }
79
80 // Extract <collector#> from MTGGoldfish variant markers before stripping
81 let variantCollectorNumber: string | undefined;
82 const collectorInVariant = trimmedLine.match(/<(\d+[a-zâ
â ]?)>/i);
83 if (collectorInVariant) {
84 variantCollectorNumber = collectorInVariant[1];
85 }
86
87 // Strip format markers
88 let remaining = stripMarkers(trimmedLine, options.format);
89
90 // Extract tags (#tag #!global #multi word tag)
91 // Tags start at first # and go to end of line (after stripping other markers)
92 let tags: string[] = [];
93 const firstHashIndex = remaining.indexOf("#");
94 if (firstHashIndex !== -1) {
95 const tagsPart = remaining.slice(firstHashIndex);
96 remaining = remaining.slice(0, firstHashIndex).trim();
97
98 // Split by # and process each tag
99 tags = Array.from(
100 new Set(
101 tagsPart
102 .split("#")
103 .map((t) => t.trim())
104 .filter((t) => t.length > 0)
105 .map((t) => (t.startsWith("!") ? t.slice(1).trim() : t)),
106 ),
107 );
108 }
109
110 // Parse quantity: "4 Name" or "4x Name"
111 let quantity = 1;
112 const quantityMatch = remaining.match(/^(\d+)x?\s+/i);
113 if (quantityMatch) {
114 quantity = Math.max(1, Number.parseInt(quantityMatch[1], 10));
115 remaining = remaining.slice(quantityMatch[0].length);
116 }
117
118 // Try XMage format first: [SET:123] or [SET] before name (most distinctive)
119 // Use [^\]]+ for collector number to handle any characters (letters, â
, â , etc.)
120 const xmageMatch = remaining.match(
121 /^\[([A-Z0-9]{2,5})(?::([^\]]+))?\]\s+(.+)$/i,
122 );
123 if (xmageMatch) {
124 return {
125 quantity,
126 name: xmageMatch[3].trim(),
127 setCode: xmageMatch[1].toUpperCase(),
128 collectorNumber: xmageMatch[2],
129 tags: [...new Set(tags)],
130 raw: options.raw,
131 };
132 }
133
134 // Try MTGGoldfish format: Name [SET] at end
135 const goldfishMatch = remaining.match(/^(.+?)\s+\[([A-Z0-9]{2,5})\]\s*$/i);
136 if (goldfishMatch) {
137 return {
138 quantity,
139 name: goldfishMatch[1].trim(),
140 setCode: goldfishMatch[2].toUpperCase(),
141 collectorNumber: variantCollectorNumber,
142 tags: [...new Set(tags)],
143 raw: options.raw,
144 };
145 }
146
147 // Try Arena/Moxfield format: Name (SET) 123
148 const arenaMatch = remaining.match(
149 /^(.+?)\s+\(([A-Z0-9]{2,5})\)(?:\s+(\S+))?\s*$/i,
150 );
151 if (arenaMatch) {
152 return {
153 quantity,
154 name: arenaMatch[1].trim(),
155 setCode: arenaMatch[2].toUpperCase(),
156 collectorNumber: arenaMatch[3],
157 tags: [...new Set(tags)],
158 raw: options.raw,
159 };
160 }
161
162 // No set code - just card name
163 const name = remaining.trim();
164
165 // Reject malformed lines with no actual card name
166 // Also reject lines that are just quantity prefixes without actual card names
167 // (e.g., "4", "4x", "100") - these are clearly malformed
168 if (!name || /^\d+x?$/i.test(name)) {
169 return null;
170 }
171
172 return {
173 quantity,
174 name,
175 tags: [...new Set(tags)],
176 raw: options.raw,
177 };
178}
179
180// Known section names that shouldn't be treated as categories
181const SECTION_NAMES = new Set([
182 "commander",
183 "companion",
184 "mainboard",
185 "main",
186 "deck",
187 "sideboard",
188 "side",
189 "maybeboard",
190 "maybe",
191]);
192
193/**
194 * Check if a line is a category header (not a section or card).
195 * - Archidekt: Line that doesn't start with a digit (not a card line)
196 * - Deckstats: //category comment that's not a known section
197 */
198function parseCategoryHeader(line: string, format: string): string | undefined {
199 // Deckstats: //category (but not //Main, //Sideboard, etc.)
200 if (format === "deckstats" && line.startsWith("//")) {
201 const category = line.slice(2).trim();
202 const lower = category.toLowerCase();
203 if (category && !SECTION_NAMES.has(lower) && !lower.startsWith("name:")) {
204 return category;
205 }
206 }
207
208 // Archidekt: Line that doesn't start with a digit and isn't a section name
209 if (format === "archidekt" && line && !/^\d/.test(line)) {
210 const lower = line.toLowerCase();
211 if (!SECTION_NAMES.has(lower)) {
212 return line;
213 }
214 }
215
216 return undefined;
217}
218
219/**
220 * Parse a complete deck list with sections.
221 *
222 * Auto-detects format if not specified. Uses format hint to resolve
223 * ambiguous situations (e.g., blank line handling).
224 */
225export function parseDeck(text: string, options?: ParseOptions): ParsedDeck {
226 const format = options?.format ?? detectFormat(text);
227 const stripRedundantTypeTags = options?.stripRedundantTypeTags ?? true;
228 const lines = text.split("\n");
229
230 const deck: ParsedDeck = {
231 commander: [],
232 mainboard: [],
233 sideboard: [],
234 maybeboard: [],
235 format,
236 };
237
238 let currentSection: DeckSection = "mainboard";
239 let currentCategory: string | undefined;
240 let sawBlankLine = false;
241 let hasExplicitSections = false;
242
243 for (const line of lines) {
244 const trimmed = line.trim();
245
246 // Check for section marker (Arena headers, //Section, etc.)
247 const sectionResult = parseSectionMarker(trimmed);
248 if (sectionResult) {
249 if (sectionResult.consumeLine) {
250 currentSection = sectionResult.section;
251 currentCategory = undefined; // Reset category on section change
252 hasExplicitSections = true;
253 sawBlankLine = false;
254 continue;
255 }
256 }
257
258 // Check for category header (Archidekt "Burn", Deckstats "//burn")
259 const category = parseCategoryHeader(trimmed, format);
260 if (category !== undefined) {
261 currentCategory = category;
262 // Category headers implicitly switch to mainboard (unless already in a specific section)
263 if (currentSection === "commander") {
264 currentSection = "mainboard";
265 }
266 continue;
267 }
268
269 // Handle blank lines
270 if (!trimmed) {
271 sawBlankLine = true;
272 continue;
273 }
274
275 // XMage NAME: metadata line
276 if (trimmed.startsWith("NAME:")) {
277 deck.name = trimmed.slice(5).trim();
278 continue;
279 }
280
281 // Skip XMage LAYOUT lines
282 if (trimmed.startsWith("LAYOUT ")) {
283 continue;
284 }
285
286 // Arena/TappedOut "About" header and "Name ..." line
287 if (/^About$/i.test(trimmed)) {
288 continue;
289 }
290 if (/^Name\s+/i.test(trimmed)) {
291 deck.name = trimmed.slice(5).trim();
292 continue;
293 }
294
295 // Deckstats //NAME: comment
296 if (trimmed.startsWith("//NAME:")) {
297 deck.name = trimmed.slice(7).trim();
298 continue;
299 }
300
301 // Check for inline section markers (SB:, [Sideboard], # !Commander)
302 const inlineResult = extractInlineSection(trimmed, {
303 format,
304 stripRedundantTypeTags,
305 });
306 let effectiveSection: DeckSection = inlineResult.section ?? currentSection;
307 const cardLine = inlineResult.cardLine;
308
309 // If inline section changed, reset category
310 if (inlineResult.section && inlineResult.section !== currentSection) {
311 currentCategory = undefined;
312 }
313
314 // Format-specific: blank line as sideboard separator
315 // Only for MTGGoldfish/generic when no explicit sections exist
316 if (
317 sawBlankLine &&
318 !hasExplicitSections &&
319 !inlineResult.section &&
320 currentSection === "mainboard" &&
321 deck.mainboard.length > 0 &&
322 (format === "mtggoldfish" || format === "generic")
323 ) {
324 currentSection = "sideboard";
325 effectiveSection = "sideboard";
326 currentCategory = undefined;
327 }
328 sawBlankLine = false;
329
330 // Parse the card line (cardLine is cleaned by extractInlineSection, trimmed is original)
331 const parsed = parseCardLine(cardLine, { raw: trimmed, format });
332 if (parsed) {
333 // Merge tags: category header + inline tags + parsed tags
334 const allTags: string[] = [];
335 if (currentCategory) {
336 allTags.push(currentCategory);
337 }
338 if (inlineResult.tags) {
339 allTags.push(...inlineResult.tags);
340 }
341 allTags.push(...parsed.tags);
342 parsed.tags = [...new Set(allTags)];
343
344 deck[effectiveSection].push(parsed);
345 }
346 }
347
348 return deck;
349}