👁️
at dev 349 lines 9.7 kB view raw
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}