···193193- **Avoid unnecessary try/catch blocks** - Don't wrap code in try/catch without a specific reason. It's not defensive coding—it's noisy and masks real errors. If a function can return null/undefined, use that instead of throwing. Let exceptions bubble naturally unless you have a specific recovery strategy
194194- **Check `typelex/*.tsp` for DeckBelcher data models** - When confused about deck structure or app schemas, read the `.tsp` files. For card data, see `src/lib/scryfall-types.ts`
195195- **NEVER use `-f` flag with rm/git/etc without justification** - Force flags suppress errors and can hide problems. Use `rm -r` not `rm -rf`, let commands fail naturally
196196+- **Prefer type-safe refactors over backward compatibility** - Don't make parameters optional or accept looser types just to avoid updating call sites. If an API change improves type safety (e.g., making a previously-implicit parameter explicit and required), update all callers. The type system catching mistakes at compile time is worth more than avoiding a few edits
196197- **ALWAYS run `npm run check` and `npm run typecheck` before considering work complete** - Verify linting, formatting, and types pass
197198- **NEVER manually fix formatting issues** - Always use `npm run format -- --write` to apply formatting fixes automatically. Manual formatting edits are error-prone and waste time
198199- `src/routeTree.gen.ts` is auto-generated - never edit manually
···2626 formatMoxfield,
2727 formatMtgo,
2828} from "./export";
2929-2929+// Line matching (for previews)
3030+export { type MatchedLine, matchLinesToParsedCards } from "./match-lines";
3031// Parsing
3132export { parseCardLine, parseDeck } from "./parse";
3233// Section utilities (for advanced use)
+77
src/lib/deck-formats/match-lines.ts
···11+/**
22+ * Match raw text lines to parsed cards, handling duplicates correctly.
33+ *
44+ * When the same card text appears multiple times (e.g., in mainboard and sideboard),
55+ * this matches them in order of appearance, ensuring each parsed card is claimed once.
66+ */
77+88+import type { DeckSection, ParsedCardLine, ParsedDeck } from "./types";
99+1010+export interface MatchedLine {
1111+ /** Unique key for React (stable unless line content changes) */
1212+ key: string;
1313+ /** The trimmed line text */
1414+ trimmed: string;
1515+ /** The parsed card for this line, or undefined if it's a header/metadata */
1616+ parsed?: ParsedCardLine;
1717+ /** The section this card belongs to */
1818+ section?: DeckSection;
1919+}
2020+2121+/**
2222+ * Match raw text lines to their parsed cards and sections.
2323+ *
2424+ * Handles duplicate lines correctly by claiming parsed cards in order.
2525+ * Each line gets a stable key (content-based, not index-based) for React.
2626+ *
2727+ * @param lines - Raw text lines from the textarea
2828+ * @param parsedDeck - The parsed deck with cards organized by section
2929+ * @returns Array parallel to `lines` with matched card info
3030+ */
3131+export function matchLinesToParsedCards(
3232+ lines: string[],
3333+ parsedDeck: ParsedDeck,
3434+): MatchedLine[] {
3535+ // Build ordered array of all parsed cards with their sections
3636+ const cardsBySection: { section: DeckSection; card: ParsedCardLine }[] = [];
3737+ for (const card of parsedDeck.commander)
3838+ cardsBySection.push({ section: "commander", card });
3939+ for (const card of parsedDeck.mainboard)
4040+ cardsBySection.push({ section: "mainboard", card });
4141+ for (const card of parsedDeck.sideboard)
4242+ cardsBySection.push({ section: "sideboard", card });
4343+ for (const card of parsedDeck.maybeboard)
4444+ cardsBySection.push({ section: "maybeboard", card });
4545+4646+ // Track which parsed cards have been claimed
4747+ const claimed = new Set<number>();
4848+ // Track occurrence counts for key generation
4949+ const counts = new Map<string, number>();
5050+5151+ return lines.map((line) => {
5252+ const trimmed = line.trim();
5353+ const occurrence = counts.get(trimmed) ?? 0;
5454+ counts.set(trimmed, occurrence + 1);
5555+ const key = `${trimmed}:${occurrence}`;
5656+5757+ if (!trimmed) {
5858+ return { key, trimmed };
5959+ }
6060+6161+ // Find the first unclaimed parsed card with matching raw text
6262+ for (let j = 0; j < cardsBySection.length; j++) {
6363+ if (!claimed.has(j) && cardsBySection[j].card.raw === trimmed) {
6464+ claimed.add(j);
6565+ return {
6666+ key,
6767+ trimmed,
6868+ parsed: cardsBySection[j].card,
6969+ section: cardsBySection[j].section,
7070+ };
7171+ }
7272+ }
7373+7474+ // No match found - this line is a section header or metadata
7575+ return { key, trimmed };
7676+ });
7777+}
+58-21
src/lib/deck-formats/parse.ts
···2020 ParseOptions,
2121} from "./types";
22222323+interface ParseCardLineOptions {
2424+ /** Original raw line to store in result (before any marker stripping) */
2525+ raw: string;
2626+ /** Format hint for format-specific marker handling */
2727+ format?: string;
2828+}
2929+3030+/**
3131+ * Strip format-specific markers from a card line.
3232+ *
3333+ * Removes visual markers that don't affect card identity:
3434+ * - *F*, *A* (Moxfield foil/alter)
3535+ * - (F) at end (MTGGoldfish foil)
3636+ * - ^...^ (Archidekt color markers)
3737+ * - <...> (MTGGoldfish variant markers)
3838+ * - [...] (Archidekt category markers, unless XMage/MTGGoldfish format)
3939+ */
4040+export function stripMarkers(line: string, format?: string): string {
4141+ let result = line;
4242+4343+ // Strip *F* (foil) and *A* (alter) markers (Moxfield style)
4444+ result = result.replace(/\s*\*[FA]\*\s*/g, " ");
4545+4646+ // Strip (F) foil marker at end (MTGGoldfish style)
4747+ result = result.replace(/\s*\(F\)\s*$/i, "");
4848+4949+ // Strip ^Tag,#color^ markers (Archidekt)
5050+ result = result.replace(/\s*\^[^^]+\^\s*/g, " ");
5151+5252+ // Strip <variant> markers (MTGGoldfish)
5353+ result = result.replace(/<[^>]+>/g, " ");
5454+5555+ // Strip [...] category markers (Archidekt) - but not for XMage/MTGGoldfish
5656+ // which use brackets for set codes
5757+ if (format !== "xmage" && format !== "mtggoldfish") {
5858+ result = result.replace(/\s*\[[^\]]+\]/g, "");
5959+ }
6060+6161+ // Normalize whitespace
6262+ return result.replace(/\s+/g, " ").trim();
6363+}
6464+2365/**
2466 * Parse a single line of card text.
2567 *
2668 * Handles all format variations for quantity, set code, and collector number.
2769 * Tries patterns in order of specificity - most distinctive first.
2870 */
2929-export function parseCardLine(line: string): ParsedCardLine | null {
3030- let remaining = line.trim();
3131- if (!remaining) {
7171+export function parseCardLine(
7272+ line: string,
7373+ options: ParseCardLineOptions,
7474+): ParsedCardLine | null {
7575+ const trimmedLine = line.trim();
7676+ if (!trimmedLine) {
3277 return null;
3378 }
34793535- // Strip *F* (foil) and *A* (alter) markers
3636- remaining = remaining.replace(/\s*\*[FA]\*\s*/g, " ").trim();
3737-3838- // Strip ^Tag,#color^ markers (Archidekt)
3939- remaining = remaining.replace(/\s*\^[^^]+\^\s*/g, " ").trim();
4040-4180 // Extract <collector#> from MTGGoldfish variant markers before stripping
4281 let variantCollectorNumber: string | undefined;
4343- const collectorInVariant = remaining.match(/<(\d+[a-z★†]?)>/i);
8282+ const collectorInVariant = trimmedLine.match(/<(\d+[a-z★†]?)>/i);
4483 if (collectorInVariant) {
4584 variantCollectorNumber = collectorInVariant[1];
4685 }
4747- // Strip <variant> markers (MTGGoldfish)
4848- remaining = remaining
4949- .replace(/<[^>]+>/g, " ")
5050- .replace(/\s+/g, " ")
5151- .trim();
8686+8787+ // Strip format markers
8888+ let remaining = stripMarkers(trimmedLine, options.format);
52895390 // Extract tags (#tag #!global #multi word tag)
5491 // Tags start at first # and go to end of line (after stripping other markers)
···90127 setCode: xmageMatch[1].toUpperCase(),
91128 collectorNumber: xmageMatch[2],
92129 tags: [...new Set(tags)],
9393- raw: line.trim(),
130130+ raw: options.raw,
94131 };
95132 }
96133···103140 setCode: goldfishMatch[2].toUpperCase(),
104141 collectorNumber: variantCollectorNumber,
105142 tags: [...new Set(tags)],
106106- raw: line.trim(),
143143+ raw: options.raw,
107144 };
108145 }
109146···118155 setCode: arenaMatch[2].toUpperCase(),
119156 collectorNumber: arenaMatch[3],
120157 tags: [...new Set(tags)],
121121- raw: line.trim(),
158158+ raw: options.raw,
122159 };
123160 }
124161···136173 quantity,
137174 name,
138175 tags: [...new Set(tags)],
139139- raw: line.trim(),
176176+ raw: options.raw,
140177 };
141178}
142179···290327 }
291328 sawBlankLine = false;
292329293293- // Parse the card line
294294- const parsed = parseCardLine(cardLine);
330330+ // Parse the card line (cardLine is cleaned by extractInlineSection, trimmed is original)
331331+ const parsed = parseCardLine(cardLine, { raw: trimmed, format });
295332 if (parsed) {
296333 // Merge tags: category header + inline tags + parsed tags
297334 const allTags: string[] = [];
···85858686### Medium Priority
87878888+#### Bulk edit: use deck-formats parser and line matching
8989+- **Location**: `src/routes/profile/$did/deck/$rkey/bulk-edit.tsx`
9090+- **Issue**: Uses old `parseCardList` from deck-import.ts, and `parsedByRaw` map loses duplicate lines in preview
9191+- **Fix**: Switch to `parseDeck` from deck-formats, use `matchLinesToParsedCards` for preview line matching
9292+- **Effort**: Small (1-2 hours)
9393+8894#### Memoize regex patterns in getSourceTempo
8995- **Location**: `src/lib/deck-stats.ts:148-225`
9096- **Issue**: Regex patterns compiled on every function call, no memoization