diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 363be66..7cf3d32 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -10,7 +10,7 @@ permissions: contents: read env: - version: '1.8.${{ github.run_number }}' + version: '1.9.${{ github.run_number }}' nodeVersion: '22' jobs: diff --git a/esbuild.config.mjs b/esbuild.config.mjs index f66d3fd..675d10a 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -32,6 +32,9 @@ const buildOptions = { '@lezer/highlight', '@lezer/lr', ...builtinModules], + loader: { + ".svg": "text", + }, format: 'cjs', target: 'es2018', logLevel: "info", diff --git a/manifest.json b/manifest.json index 97d0559..1b2f4c2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "mtg-deck", "name": "MTG Deck", - "version": "1.8.57", + "version": "1.9.58", "minAppVersion": "0.15.0", "description": "Display your MTG decks and card lists in your notes.", "author": "Samir Boulema", diff --git a/package.json b/package.json index 572eba4..27531c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mtg-deck", - "version": "1.8.57", + "version": "1.9.58", "description": "Display your MTG decks and card lists in your notes.", "main": "main.js", "scripts": { diff --git a/src/assets/card-types/artifact.svg b/src/assets/card-types/artifact.svg new file mode 100644 index 0000000..fe74ed4 --- /dev/null +++ b/src/assets/card-types/artifact.svg @@ -0,0 +1,5 @@ + + +artifact + + diff --git a/src/assets/card-types/creature.svg b/src/assets/card-types/creature.svg new file mode 100644 index 0000000..5329663 --- /dev/null +++ b/src/assets/card-types/creature.svg @@ -0,0 +1,5 @@ + + +creature + + diff --git a/src/assets/card-types/enchantment.svg b/src/assets/card-types/enchantment.svg new file mode 100644 index 0000000..3878bd2 --- /dev/null +++ b/src/assets/card-types/enchantment.svg @@ -0,0 +1,5 @@ + + +enchantment + + diff --git a/src/assets/card-types/instant.svg b/src/assets/card-types/instant.svg new file mode 100644 index 0000000..795386f --- /dev/null +++ b/src/assets/card-types/instant.svg @@ -0,0 +1,5 @@ + + +instant + + diff --git a/src/assets/card-types/land.svg b/src/assets/card-types/land.svg new file mode 100644 index 0000000..3d4b756 --- /dev/null +++ b/src/assets/card-types/land.svg @@ -0,0 +1,5 @@ + + +land + + diff --git a/src/assets/card-types/planeswalker.svg b/src/assets/card-types/planeswalker.svg new file mode 100644 index 0000000..da65a6c --- /dev/null +++ b/src/assets/card-types/planeswalker.svg @@ -0,0 +1,5 @@ + + +planeswalker + + diff --git a/src/assets/card-types/sorcery.svg b/src/assets/card-types/sorcery.svg new file mode 100644 index 0000000..93a84ee --- /dev/null +++ b/src/assets/card-types/sorcery.svg @@ -0,0 +1,5 @@ + + +sorcery + + diff --git a/src/card-grid.ts b/src/card-grid.ts new file mode 100644 index 0000000..e1e14ed --- /dev/null +++ b/src/card-grid.ts @@ -0,0 +1,49 @@ +import { nameToId } from "./collection"; +import { CardData } from "./scryfall"; +import { Line } from "./types"; + +export const buildCardGrid = ( + container: HTMLElement, + lines: Line[], + cardDataByCardId: Record +): HTMLElement => { + const cardGrid = container.createDiv({ cls: "decklist__card-grid" }); + cardGrid.style.display = "none"; + + lines + .filter((line) => line.lineType === "card" && line.cardName) + .forEach((line) => { + const cardId = nameToId(line.cardName!); + const cardInfo = cardDataByCardId[cardId]; + const imgUri = + cardInfo?.image_uris?.normal ?? + cardInfo?.card_faces?.[0]?.image_uris?.normal; + + if (!imgUri) return; + + const cardEl = cardGrid.createDiv({ cls: "decklist__card-grid__item" }); + cardEl.createEl("img", { + attr: { src: imgUri }, + cls: "decklist__card-grid__image", + }); + cardEl.createSpan({ + cls: "decklist__card-grid__count", + text: `${line.cardCount}x`, + }); + }); + + return cardGrid; +}; + +export const setupGridToggle = ( + toggleBtn: HTMLElement, + cardGrid: HTMLElement, + sectionList: HTMLElement +): void => { + toggleBtn.addEventListener("click", () => { + const isGridVisible = cardGrid.style.display !== "none"; + cardGrid.style.display = isGridVisible ? "none" : "flex"; + sectionList.style.display = isGridVisible ? "" : "none"; + toggleBtn.textContent = isGridVisible ? "Visual View" : "List View"; + }); +}; diff --git a/src/card-preview.ts b/src/card-preview.ts new file mode 100644 index 0000000..4121569 --- /dev/null +++ b/src/card-preview.ts @@ -0,0 +1,75 @@ +import { nameToId } from "./collection"; +import { CardData } from "./scryfall"; + +export const setupCardPreview = ( + lineEl: HTMLElement, + cardName: string, + cardDataByCardId: Record, + imgElContainer: HTMLElement, + imgEl: HTMLImageElement, + sectionList: HTMLElement +): void => { + const cardId = nameToId(cardName); + const cardInfo = cardDataByCardId[cardId]; + const isDoubleFaced = + (cardInfo?.card_faces?.length ?? 0) > 1 && !cardInfo?.image_uris; + + let faceIndex = 0; + + const getImgUri = ( + info: CardData, + index: number + ): string | undefined => { + if (info.image_uris) { + return info.image_uris.large; + } + return info.card_faces?.[index]?.image_uris?.large; + }; + + lineEl.addEventListener("mouseenter", () => { + const cardInfo = cardDataByCardId[cardId]; + if (!cardInfo) return; + + faceIndex = 0; + + imgElContainer.style.display = "block"; + imgElContainer.style.top = `${lineEl.offsetTop}px`; + imgElContainer.style.right = "50px"; + imgElContainer.style.left = "auto"; + + const imgUri = getImgUri(cardInfo, faceIndex); + if (imgUri) { + imgEl.src = imgUri; + } + + if (isDoubleFaced) { + imgEl.style.cursor = "pointer"; + imgEl.onclick = () => { + faceIndex = faceIndex === 0 ? 1 : 0; + const uri = getImgUri(cardInfo, faceIndex); + if (uri) { + imgEl.src = uri; + } + }; + } else { + imgEl.style.cursor = "default"; + imgEl.onclick = null; + } + }); + + lineEl.addEventListener("mouseleave", (e) => { + if (!imgElContainer.contains(e.relatedTarget as Node)) { + imgElContainer.style.display = "none"; + imgEl.src = ""; + imgEl.onclick = null; + } + }); + + imgElContainer.addEventListener("mouseleave", (e) => { + if (!sectionList.contains(e.relatedTarget as Node)) { + imgElContainer.style.display = "none"; + imgEl.src = ""; + imgEl.onclick = null; + } + }); +}; diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..27a9348 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,94 @@ +import { CardCounts, nameToId } from "./collection"; +import { CardIdentifier } from "./scryfall"; +import { COMMENT_DELIMITER, Line } from "./types"; + +const lineMatchRE = /^(\d+)\s+(.*?)(\s+\(([A-Za-z0-9]{3})\)\s+0*(\d+))?$/; +const blankLineRE = /^\s+$/; +const headingMatchRE = new RegExp("^[^[0-9|" + COMMENT_DELIMITER + "]"); + +export const parseLines = ( + rawLines: string[], + cardCounts: CardCounts +): Line[] => { + // This means global counts are not available because they are missing or no collection files are present + const shouldSkipGlobalCounts = !Object.keys(cardCounts).length; + + return rawLines.map((line) => { + // Handle blank lines + if (!line.length || line.match(blankLineRE)) { + return { lineType: "blank" }; + } + + // Handle headings + if (line.match(headingMatchRE)) { + return { lineType: "section", text: line }; + } + + // Handle comment lines + if (line.startsWith(COMMENT_DELIMITER + " ")) { + return { lineType: "comment", comments: [line] }; + } + + let lineWithoutComments: string = line; + const comments: string[] = []; + + // Handle inline comments + if (line.includes(COMMENT_DELIMITER)) { + const lineAndComments = line.split(COMMENT_DELIMITER); + lineAndComments.slice(1).forEach((comment) => comments.push(comment)); + lineWithoutComments = lineAndComments[0]; + } + + const lineParts = lineWithoutComments.match(lineMatchRE); + + if (lineParts == null) { + return { lineType: "error", errors: [`invalid line: ${line}`] }; + } + + const cardCount: number = parseInt(lineParts[1] || "0"); + const cardName: string = lineParts[2]; + const cardId: string = nameToId(cardName); + const cardSet: string = lineParts[4]; + const cardNumber: string = lineParts[5]; + const errors: string[] = []; + + let globalCount = null; + + if (!shouldSkipGlobalCounts) { + globalCount = cardCounts[cardId] || 0; + } + + if (cardName.length === 0) { + errors.push(`Unable to parse card name from: ${line}`); + } + + return { + lineType: "card", + cardCount, + globalCount, + cardName, + cardSet, + cardNumber, + comments, + errors, + }; + }); +}; + +export const buildDistinctCardList = (lines: Line[]): CardIdentifier[] => { + return Array.from( + new Set( + lines.flatMap((line): CardIdentifier[] => { + if (line.lineType !== "card") { + return []; + } else if (line.cardSet === undefined) { + return [{ name: nameToId(line.cardName) }]; + } else if (line.cardNumber !== undefined) { + return [{ set: line.cardSet, collector_number: line.cardNumber }]; + } else { + return []; + } + }) + ) + ); +}; diff --git a/src/pricing.ts b/src/pricing.ts new file mode 100644 index 0000000..d86b9cd --- /dev/null +++ b/src/pricing.ts @@ -0,0 +1,32 @@ +import { nameToId } from "./collection"; +import { CardData } from "./scryfall"; +import { ObsidianPluginMtgSettings } from "./settings"; + +export const currencyMapping: Record = { + usd: "$", + eur: "€", + tix: "Tx", +}; + +export const getCardPrice = ( + cardName: string, + cardDataById: Record, + settings: ObsidianPluginMtgSettings +): string | null => { + const cardId = nameToId(cardName); + const cardData = cardDataById[cardId]; + const preferredCurrency = settings.decklist.preferredCurrency; + const showCardPrices = settings.decklist.showCardPrices; + + if (!cardData || !showCardPrices) { + return null; + } + + if (preferredCurrency === "eur") { + return cardData.prices?.eur || null; + } else if (preferredCurrency === "tix") { + return cardData.prices?.tix || null; + } else { + return cardData.prices?.usd || null; + } +}; diff --git a/src/renderer.ts b/src/renderer.ts index be5b265..8221f7c 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,239 +1,12 @@ import { CardCounts, nameToId, UNKNOWN_CARD } from "./collection"; -import { - CardIdentifier, - CardData, - getMultipleCardData, - MAX_SCRYFALL_BATCH_SIZE, - ScryfallResponse, -} from "./scryfall"; +import { CardIdentifier, CardData, fetchCardDataFromScryfall } from "./scryfall"; import { ObsidianPluginMtgSettings } from "./settings"; +import { DEFAULT_SECTION_NAME, SKIP_SECTION_NAMES, Line } from "./types"; +import { parseLines, buildDistinctCardList } from "./parser"; +import { getCardPrice, currencyMapping } from "./pricing"; +import { renderSection, renderSectionSkeleton } from "./section-renderer"; -const DEFAULT_SECTION_NAME = "Deck:"; -const COMMENT_DELIMITER = "#"; -const SKIP_SECTION_NAMES = ["About", "Name"]; - -interface Line { - lineType: "card" | "section" | "error" | "blank" | "comment"; - cardCount?: number; - globalCount?: number | null; - cardName?: string; - cardSet?: string; - cardNumber?: string; - comments?: string[]; - errors?: string[]; - text?: string; -} - -const lineMatchRE = /^(\d+)\s+(.*?)(\s+\(([A-Za-z0-9]{3})\)\s+0*(\d+))?$/; -const blankLineRE = /^\s+$/; -const headingMatchRE = new RegExp("^[^[0-9|" + COMMENT_DELIMITER + "]"); - -const currencyMapping = { - usd: "$", - eur: "€", - tix: "Tx", -}; - -const cardTypeOrder = [ - "Planeswalker", - "Creature", - "Sorcery", - "Instant", - "Artifact", - "Enchantment", - "Land", -]; - -const getTypeOrder = (line: Line, cardDataByCardId: Record): number => { - if (line.lineType !== "card" || !line.cardName) { - return 999; - } - - const cardId = nameToId(line.cardName); - const cardInfo = cardDataByCardId[cardId]; - - if (!cardInfo?.type_line) { - return 999; - } - - const index = cardTypeOrder.findIndex(type => cardInfo.type_line!.includes(type)); - - return index === -1 ? 999 : index; -}; - -export const getCardPrice = ( - cardName: string, - cardDataById: Record, - settings: ObsidianPluginMtgSettings -) => { - const cardId = nameToId(cardName); - const cardData = cardDataById[cardId]; - const preferredCurrency = settings.decklist.preferredCurrency; - const showCardPrices = settings.decklist.showCardPrices; - - if (!cardData || !showCardPrices) { - return null; - } else { - if (preferredCurrency === "eur") { - return cardData.prices?.eur || null; - } else if (preferredCurrency === "tix") { - return cardData.prices?.tix || null; - } else { - return cardData.prices?.usd || null; - } - } -}; - -export const parseLines = ( - rawLines: string[], - cardCounts: CardCounts -): Line[] => { - // This means global counts are not available because they are missing or no collection files are present - const shouldSkipGlobalCounts = !Object.keys(cardCounts).length; - - // count, collection_count, card name, comment - return rawLines.map((line) => { - // Handle blank lines - if (!line.length || line.match(blankLineRE)) { - return { - lineType: "blank", - }; - } - - // Handle headings - if (line.match(headingMatchRE)) { - return { - lineType: "section", - text: line, - }; - } - - // Handle comment lines - if (line.startsWith(COMMENT_DELIMITER + " ")) { - return { - lineType: "comment", - comments: [line], - }; - } - - let lineWithoutComments: string = line; - const comments: string[] = []; - - // Handle comments - if (line.includes(COMMENT_DELIMITER)) { - const lineAndComments = line.split(COMMENT_DELIMITER); - lineAndComments - .slice(1) - .forEach((comment) => comments.push(comment)); - lineWithoutComments = lineAndComments[0]; - } - - // Handle card lines - const lineParts = lineWithoutComments.match(lineMatchRE); - - // Handle invalid line - if (lineParts == null) { - return { - lineType: "error", - errors: [`invalid line: ${line}`], - }; - } else { - const cardCount: number = parseInt(lineParts[1] || "0"); - const cardName: string = lineParts[2]; - const cardId: string = nameToId(cardName); - const cardSet: string = lineParts[4]; - const cardNumber: string = lineParts[5]; - const errors: string[] = []; - - let globalCount = null; - - if (!shouldSkipGlobalCounts) { - globalCount = cardCounts[cardId] || 0; - } - - if (cardName.length === 0) { - errors.push(`Unable to parse card name from: ${line}`); - } - - return { - lineType: "card", - cardCount, - globalCount, - cardName, - cardSet, - cardNumber, - comments, - errors, - }; - } - }); -}; - -export const buildDistinctCardList = (lines: Line[]): CardIdentifier[] => { - return Array.from( - new Set( - lines.flatMap((line): CardIdentifier[] => { - if (line.lineType !== "card") { - return []; - } else if (line.cardSet === undefined) { - return [ - { - name: nameToId(line.cardName), - }, - ]; - } else if (line.cardNumber !== undefined) { - return [ - { - set: line.cardSet, - collector_number: line.cardNumber, - }, - ]; - } else { - // cardSet is defined but cardNumber is - // undefined. Should never happen. - return []; - } - }) - ) - ); -}; - -export const fetchCardDataFromScryfall = async ( - distinctCards: CardIdentifier[] -): Promise> => { - // Fetch in batches of 75, since that's the limit of Scryfall batch sizes - const batches: CardIdentifier[][] = []; - let currentBatch: CardIdentifier[] = []; - batches.push(currentBatch); - distinctCards.forEach((identifier: CardIdentifier, idx: number) => { - if (currentBatch.length === MAX_SCRYFALL_BATCH_SIZE) { - batches.push(currentBatch); - // Make new batch - currentBatch = []; - } - currentBatch.push(identifier); - }); - // Add remaining cards - batches.push(currentBatch); - - const cardDataInBatches: ScryfallResponse[] = await Promise.all( - batches.map((batch) => getMultipleCardData(batch)) - ); - const cardDataByCardId: Record = {}; - const cards: CardData[] = []; - - cardDataInBatches.forEach((batch) => { - batch.data.forEach((card: CardData) => { - cards.push(card); - if (card.name) { - const cardId = nameToId(card.name); - cardDataByCardId[cardId] = card; - } - }); - }); - - return cardDataByCardId; -}; +export { getCardPrice, fetchCardDataFromScryfall }; export const renderDecklist = async ( root: Element, @@ -242,498 +15,82 @@ export const renderDecklist = async ( settings: ObsidianPluginMtgSettings, dataFetcher = fetchCardDataFromScryfall ): Promise => { - const containerEl = root.createDiv({ cls: "decklist" }); - - const lines: string[] = source.split("\n"); - - const parsedLines: Line[] = parseLines(lines, cardCounts); - - const linesBySection: Record = {}; - - let currentSection = DEFAULT_SECTION_NAME; - const sections: string[] = []; - - parsedLines.forEach((line, idx) => { - if (idx == 0 && line.lineType !== "section") { - currentSection = `${currentSection}`; - sections.push(`${currentSection}`); - } - if (line.lineType === "section") { - currentSection = line.text || DEFAULT_SECTION_NAME; - sections.push(`${currentSection}`); - } else { - if (!linesBySection[currentSection]) { - linesBySection[currentSection] = []; - } - linesBySection[currentSection].push(line); - } - }); - - // Create list of distinct cards - const distinctCards: CardIdentifier[] = buildDistinctCardList(parsedLines); - let cardDataByCardId: Record = {}; - - // Try to fetch data from Scryfall - try { - cardDataByCardId = await dataFetcher(distinctCards); - } catch (err) { - console.error("Error fetching card data: ", err); - } - - // Determines whether any card info was found for the cards on the list - const hasCardInfo = Object.keys(cardDataByCardId).length > 0; - - // Make elements from parsedLines - const sectionContainers: Element[] = []; - - // Footer section - const footer = containerEl.createDiv({ cls: "footer" }); - - const sectionTotalCounts: Record = sections.reduce( - (acc, curr) => ({ ...acc, [curr]: 0 }), - {} - ); - const sectionTotalCost: Record = sections.reduce( - (acc, curr) => ({ ...acc, [curr]: 0.0 }), - {} - ); - const missingCardCounts: CardCounts = {}; - - sections - .filter(section => !SKIP_SECTION_NAMES.includes(section)) - .forEach((section: string) => { - // Put the entire deck in containing div for styling - const sectionContainer = containerEl.createDiv({ cls: "decklist__section-container" }); - - const sectionHeadingContainer = sectionContainer.createDiv({ cls: "decklist__section-heading-container" }); - const toggleViewBtn = sectionHeadingContainer.createEl("button", { - text: "Visual View", - cls: "decklist__toggle-view" - }); - - const imgElContainer = sectionContainer.createDiv({ cls: "card-image-container" }); - const imgEl = imgElContainer.createEl("img", { cls: "card-image" }); - - // Create a heading - const sectionHeadingEl = sectionContainer.createEl("h3", { cls: "decklist__section-heading" }); - - // Treat "Name" section as a special case since it's not really a section but more of a metadata field for the deck, - // so we just show it as a heading without creating a table for it - if (section.startsWith("Name")) { - sectionHeadingEl.textContent = section.replace(/^Name\s+/, ""); - sectionContainers.push(sectionContainer); - return; - } - - // Create conttained for the visual deck view - const cardGrid = sectionContainer.createDiv({ cls: "decklist__card-grid" }); - cardGrid.style.display = "none"; - - // Toggle between table and grid - toggleViewBtn.addEventListener("click", () => { - const isGridVisible = cardGrid.style.display !== "none"; - cardGrid.style.display = isGridVisible ? "none" : "flex"; - sectionList.style.display = isGridVisible ? "" : "none"; - toggleViewBtn.textContent = isGridVisible ? "Visual View" : "List View"; - }); - - // Create container for the table rows - const sectionList = sectionContainer.createEl("table"); - const sectionListHead = sectionList.createEl("thead"); - const sectionListHeadRow = sectionListHead.createEl("tr"); - - sectionListHeadRow.createEl("th", { text: "Count" }); - sectionListHeadRow.createEl("th", { text: "Name", cls: "max" }); - - if (settings.decklist.showManaCosts) { - sectionListHeadRow.createEl("th", { text: "Cost" }); - } - - if (settings.decklist.showCardPrices) { - sectionListHeadRow.createEl("th", { text: "Price" }); - } - - const sectionListBody = sectionList.createEl("tbody"); - - const sectionMissingCardCounts: CardCounts = {}; - - // Sort lines by card type, preserving relative order of non-card lines - const sortedLines = [...linesBySection[section]].sort((a, b) => { - const typeOrder = getTypeOrder(a, cardDataByCardId) - getTypeOrder(b, cardDataByCardId); - - if (typeOrder !== 0) { - return typeOrder; - } - - return (a.cardName ?? "").localeCompare(b.cardName ?? ""); - }); - - // Populate grid with distinct cards - sortedLines - .filter(line => line.lineType === "card" && line.cardName) - .forEach(line => { - const cardId = nameToId(line.cardName!); - const cardInfo = cardDataByCardId[cardId]; - const imgUri = cardInfo?.image_uris?.normal - ?? cardInfo?.card_faces?.[0]?.image_uris?.normal; - - if (!imgUri) return; - - const cardEl = cardGrid.createDiv({ cls: "decklist__card-grid__item" }); - cardEl.createEl("img", { - attr: { src: imgUri }, - cls: "decklist__card-grid__image" - }); - cardEl.createSpan({ - cls: "decklist__card-grid__count", - text: `${line.cardCount}x` - }); - }); - - let previousTypeOrder = -1; - - // Create line item elements - sortedLines.forEach((line: Line) => { - const lineEl = sectionListBody.createEl("tr"); - - if (line.lineType === "card") { - const cardId = nameToId(line.cardName); - const cardInfo = cardDataByCardId[cardId]; - const currentTypeOrder = getTypeOrder(line, cardDataByCardId); - - if (currentTypeOrder !== previousTypeOrder) { - lineEl.classList.add("type-separator"); - previousTypeOrder = currentTypeOrder; - } - - // Card count cell - const cardCountCell = lineEl.createEl("td"); - - if (settings.decklist.showManaCosts) { - cardCountCell.createSpan({ cls: `card-rarity ${cardInfo?.rarity}` }); - } - - const cardCountEl = cardCountCell.createSpan({ cls: "count" }); - - // Card name cell - const cardNameCell = lineEl.createEl("td"); - const cardNameEl = cardNameCell.createSpan({ cls: "card-name" }); - const cardCommentsEl = cardNameCell.createSpan({ - cls: "comment", - text: line.comments?.join("#") || "", - }); - - // Card cost cell (if enabled in settings) - if (settings.decklist.showManaCosts) { - const cardCostCell = lineEl.createEl("td"); - const cardCostEl = cardCostCell.createSpan({ cls: "card-cost" }); - - if (line.cardName) { - const cardManaCost = cardInfo?.mana_cost - ?? cardInfo?.card_faces?.[0]?.mana_cost; - - if (cardManaCost) { - cardManaCost - .replace(/\//g, "") - .split("{") - .slice(1) - .forEach(part => { - cardCostEl.createEl("img", { - attr: { - src: `https://svgs.scryfall.io/card-symbols/${part.slice(0, -1)}.svg`, - width: 18, - height: 18, - } - }); - }); - } - } - } - - let cardPrice; - let cardPriceEl; - - if (settings.decklist.showCardPrices) { - const cardPriceCell = lineEl.createEl("td"); - cardPriceEl = cardPriceCell.createSpan({ cls: "card-price" }); - - if (line.cardName) { - cardPrice = getCardPrice( - line.cardName, - cardDataByCardId, - settings - ); - } - } - - // Add hyperlink when possible - if (line.cardName) { - const cardId = nameToId(line.cardName); - const cardInfo = cardDataByCardId[cardId]; - if ( - settings.decklist.showCardNamesAsHyperlinks && - cardInfo && - cardInfo.scryfall_uri - ) { - const cardLinkEl = cardNameEl.createEl("a"); - cardLinkEl.href = cardInfo.scryfall_uri; - cardLinkEl.textContent = `${cardInfo.name}`; - } else { - cardNameEl.textContent = `${(cardInfo && cardInfo.name) || - line.cardName || - UNKNOWN_CARD - }`; - } - } - - if (line.errors && line.errors.length) { - cardNameEl.createSpan({ - cls: "error", - text: line.errors?.join(",") || "", - }); - } - - const lineCardCount = line.cardCount || 0; - const lineGlobalCount = - line.globalCount === null ? -1 : line.globalCount || 0; - - // Show missing card counts - if (lineGlobalCount !== -1 && lineCardCount > lineGlobalCount) { - const counts = cardCountEl.createSpan({ cls: "count" }); - // Card error element - counts.createSpan({ - cls: "error", - text: `${lineGlobalCount}`, - }); - // Card counts row element - counts.createSpan({ - text: ` / ${lineCardCount}`, - }); - lineEl.classList.add("insufficient-count"); - - const cardId = nameToId(line.cardName); - missingCardCounts[cardId] = - (missingCardCounts[cardId] || 0) + - (lineCardCount - lineGlobalCount); - - sectionMissingCardCounts[cardId] = - (sectionMissingCardCounts[cardId] || 0) + - (lineCardCount - lineGlobalCount); - - if (cardPrice) { - cardPriceEl!.classList.add("insufficient-count"); - - const totalPrice: number = - lineCardCount * parseFloat(cardPrice); - const amountOwned: number = - lineGlobalCount * parseFloat(cardPrice); - - cardPriceEl!.createSpan({ - cls: "error", - text: `${currencyMapping[ - settings.decklist.preferredCurrency - ] - }${amountOwned.toFixed(2)}`, - }); - - cardPriceEl!.createSpan({ - text: ` / ${currencyMapping[ - settings.decklist.preferredCurrency - ] - }${totalPrice.toFixed(2)}`, - }); - - // Add cost to total - sectionTotalCost[section] = - sectionTotalCost[section] + (totalPrice || 0.0); - } - } else { - cardCountEl.textContent = `${lineCardCount}`; - - if (cardPrice) { - const totalPrice: number = - lineCardCount * parseFloat(cardPrice); - const displayPrice = `${currencyMapping[settings.decklist.preferredCurrency] - }${totalPrice.toFixed(2)}`; - - cardPriceEl!.textContent = displayPrice; - - // Add cost to total - sectionTotalCost[section] = - sectionTotalCost[section] + (totalPrice || 0.0); - } - } - - sectionTotalCounts[section] = - sectionTotalCounts[section] + (line.cardCount || 0); - - // Show card preview on hover - if (settings.decklist.showCardPreviews && line.cardName) { - const cardId = nameToId(line.cardName); - const cardInfo = cardDataByCardId[cardId]; - const isDoubleFaced = (cardInfo?.card_faces?.length ?? 0) > 1 - && !cardInfo?.image_uris; - - let faceIndex = 0; - - lineEl.addEventListener("mouseenter", () => { - const cardId = nameToId(line.cardName); - const cardInfo = cardDataByCardId[cardId]; - if (!cardInfo) return; - - faceIndex = 0; - - const getImgUri = (index: number): string | undefined => { - if (cardInfo.image_uris) { - return cardInfo.image_uris.large; - } - return cardInfo.card_faces?.[index]?.image_uris?.large; - }; - - imgElContainer.style.display = "block"; - imgElContainer.style.top = `${lineEl.offsetTop}px`; - imgElContainer.style.right = "50px"; - imgElContainer.style.left = "auto"; - - const imgUri = getImgUri(faceIndex); - if (imgUri) { - imgEl.src = imgUri; - } - - if (isDoubleFaced) { - imgEl.style.cursor = "pointer"; - imgEl.onclick = () => { - faceIndex = faceIndex === 0 ? 1 : 0; - const uri = getImgUri(faceIndex); - if (uri) { - imgEl.src = uri; - } - }; - } else { - imgEl.style.cursor = "default"; - imgEl.onclick = null; - } - }); - - lineEl.addEventListener("mouseleave", (e) => { - // Only hide if we're not moving onto the image container - if (!imgElContainer.contains(e.relatedTarget as Node)) { - imgElContainer.style.display = "none"; - imgEl.src = ""; - imgEl.onclick = null; - } - }); - - imgElContainer.addEventListener("mouseleave", (e) => { - // Only hide if we're not moving back onto a table row - if (!sectionList.contains(e.relatedTarget as Node)) { - imgElContainer.style.display = "none"; - imgEl.src = ""; - imgEl.onclick = null; - } - }); - } - } else if (line.lineType === "comment") { - // Comments - lineEl.createSpan({ - cls: "comment", - text: line.comments?.join(" ") || "", - }); - } - }); - - const sectionListFoot = sectionList.createEl("tfoot", { cls: "decklist__section-totals" }); - const sectionListFootRow = sectionListFoot.createEl("tr"); - const totalCardsEl = sectionListFootRow.createEl("td", { cls: "decklist__section-totals__count fit" }); - sectionListFootRow.createEl("td", { text: "Cards" }); - sectionListFootRow.createEl("td"); - - let totalCostEl; - if (hasCardInfo && settings.decklist.showCardPrices) { - totalCostEl = sectionListFootRow.createEl("td", { cls: "decklist__section-totals__cost fit" }); - } - - sectionHeadingEl.textContent = `${section}`; - - const sectionMissingCardIds = Object.keys(sectionMissingCardCounts); - - // When there are missing cards, show fraction - if (sectionMissingCardIds.length) { - // Counts - const totalMissingCountInSection = Object.values( - sectionMissingCardCounts - ).reduce((acc, v) => acc + v, 0); - - const totalCardsOwned = - sectionTotalCounts[section] - totalMissingCountInSection; - - // Errors - totalCardsEl.createSpan({ - cls: "error", - text: `${totalCardsOwned}`, - }); - - // Counts - totalCardsEl.createSpan({ - cls: "insufficient-count", - text: ` / ${sectionTotalCounts[section]}`, - }); - - const totalMissingCostInSection = Object.keys( - sectionMissingCardCounts - ).reduce((acc, cardId) => { - const countNeeded = sectionMissingCardCounts[cardId]; - const cardPrice: number = parseFloat( - getCardPrice(cardId, cardDataByCardId, settings) || "0.00" - ); - return acc + cardPrice * countNeeded; - }, 0.0); - - // Value - if (hasCardInfo && settings.decklist.showCardPrices) { - const totalValueOwned = - sectionTotalCost[section] - totalMissingCostInSection; - totalCostEl!.createSpan({ - cls: "error", - text: `${currencyMapping[settings.decklist.preferredCurrency] - }${totalValueOwned.toFixed(2)}`, - }); - - // Total value needed - totalCostEl!.createSpan({ - cls: "insufficient-count", - text: ` / ${currencyMapping[settings.decklist.preferredCurrency] - }${sectionTotalCost[section].toFixed(2)}`, - }); - } - - } else { - totalCardsEl.textContent = `${sectionTotalCounts[section]}`; - if (settings.decklist.showCardPrices) { - totalCostEl!.textContent = `${currencyMapping[settings.decklist.preferredCurrency] - }${sectionTotalCost[section].toFixed(2)}`; - } - } - - sectionContainers.push(sectionContainer); - }); - - sectionContainers.forEach((sectionContainer) => - containerEl.appendChild(sectionContainer) - ); - + const containerEl = root.createDiv({ cls: "decklist" }); + const lines = source.split("\n"); + const parsedLines = parseLines(lines, cardCounts); + + const linesBySection: Record = {}; + let currentSection = DEFAULT_SECTION_NAME; + const sections: string[] = []; + + parsedLines.forEach((line, idx) => { + if (idx === 0 && line.lineType !== "section") { + sections.push(currentSection); + } + if (line.lineType === "section") { + currentSection = line.text || DEFAULT_SECTION_NAME; + sections.push(currentSection); + } else { + if (!linesBySection[currentSection]) { + linesBySection[currentSection] = []; + } + linesBySection[currentSection].push(line); + } + }); + + const filteredSections = sections.filter(s => !SKIP_SECTION_NAMES.includes(s)); + + // Pass 1: render skeletons immediately + const sectionContainers = filteredSections.map(section => + renderSectionSkeleton(containerEl, section, linesBySection[section] ?? [], settings) + ); + sectionContainers.forEach(el => containerEl.appendChild(el)); + + const footer = containerEl.createDiv({ cls: "footer" }); + containerEl.appendChild(footer); + + // Pass 2: fetch and enrich + const distinctCards = buildDistinctCardList(parsedLines); + let cardDataByCardId: Record = {}; + + try { + cardDataByCardId = await dataFetcher(distinctCards); + } catch (err) { + console.error("Error fetching card data: ", err); + } + + const hasCardInfo = Object.keys(cardDataByCardId).length > 0; + const sectionTotalCounts: Record = filteredSections.reduce((acc, s) => ({ ...acc, [s]: 0 }), {}); + const sectionTotalCost: Record = filteredSections.reduce((acc, s) => ({ ...acc, [s]: 0.0 }), {}); + const missingCardCounts: CardCounts = {}; + + // Replace skeletons with fully rendered sections + filteredSections.forEach((section, i) => { + const enriched = renderSection(containerEl, { + section, + lines: linesBySection[section] ?? [], + cardDataByCardId, + hasCardInfo, + settings, + missingCardCounts, + sectionTotalCounts, + sectionTotalCost, + }); + sectionContainers[i].replaceWith(enriched); + }); + + // Buylist const buylistCardIds = Object.keys(missingCardCounts); const buylistCardCounts = Object.values(missingCardCounts).reduce( (acc, val) => acc + val, 0 ); - // Only show the buylist element when there are missing cards if (buylistCardIds.length && settings.decklist.showBuylist) { - // Build buylist const buylist = footer.createDiv({ cls: "buylist-container" }); - - const buylistHeader = buylist.createEl("h3", { cls: "decklist__section-heading" }); + const buylistHeader = buylist.createEl("h3", { + cls: "decklist__section-heading", + }); buylistHeader.textContent = "Buylist: "; let totalCostOfBuylist = 0.0; @@ -741,54 +98,38 @@ export const renderDecklist = async ( buylistCardIds.forEach((cardId) => { const cardInfo = cardDataByCardId[cardId]; - let buylistLine = ""; - const countNeeded = missingCardCounts[cardId]; - - // Add count - buylistLine += `${countNeeded}` + " "; + let buylistLine = `${countNeeded} `; if (cardInfo) { const cardName = cardInfo.name || ""; - buylistLine += `${cardName}`; - - // Retrieve price - const cardPrice: number = parseFloat( + buylistLine += cardName; + const cardPrice = parseFloat( getCardPrice(cardName, cardDataByCardId, settings) || "0.00" ); - - totalCostOfBuylist = - totalCostOfBuylist + cardPrice * countNeeded; - - buylistLines += buylistLine + "\n"; + totalCostOfBuylist += cardPrice * countNeeded; } else { - // Card name might be unknown - buylistLines += buylistLine + `${cardId || UNKNOWN_CARD}\n`; + buylistLine += cardId || UNKNOWN_CARD; } + + buylistLines += buylistLine + "\n"; }); - const buylistPre = buylist.createEl("pre", { cls: "buylist-container" }); - buylistPre.textContent = buylistLines; - + buylist.createEl("pre", { cls: "buylist-container" }).textContent = + buylistLines; buylist.createEl("hr"); const buylistLineEl = buylist.createDiv({ cls: "buylist-line" }); - buylistLineEl.createSpan({ cls: "decklist__section-totals__count", text: `${buylistCardCounts} `, }); - - buylistLineEl.createSpan({ - cls: "card-name", - text: "cards", - }); + buylistLineEl.createSpan({ cls: "card-name", text: "cards" }); if (hasCardInfo && settings.decklist.showCardPrices) { buylistLineEl.createSpan({ cls: "decklist__section-totals", - text: `${currencyMapping[settings.decklist.preferredCurrency] - }${totalCostOfBuylist.toFixed(2)}`, + text: `${currencyMapping[settings.decklist.preferredCurrency]}${totalCostOfBuylist.toFixed(2)}`, }); } } @@ -796,4 +137,4 @@ export const renderDecklist = async ( containerEl.appendChild(footer); return containerEl; -}; \ No newline at end of file +}; diff --git a/src/scryfall.ts b/src/scryfall.ts index 7bf615c..25a5960 100644 --- a/src/scryfall.ts +++ b/src/scryfall.ts @@ -1,3 +1,4 @@ +import { nameToId } from "./collection"; import { promiseWrappedRequest } from "./http"; export type CardIdentifier = @@ -187,4 +188,39 @@ export const getMultipleCardData = async ( }; return request(params); +}; + +export const fetchCardDataFromScryfall = async ( + distinctCards: CardIdentifier[] +): Promise> => { + const batches: CardIdentifier[][] = []; + let currentBatch: CardIdentifier[] = []; + batches.push(currentBatch); + + distinctCards.forEach((identifier: CardIdentifier) => { + if (currentBatch.length === MAX_SCRYFALL_BATCH_SIZE) { + batches.push(currentBatch); + currentBatch = []; + } + currentBatch.push(identifier); + }); + + batches.push(currentBatch); + + const cardDataInBatches: ScryfallResponse[] = await Promise.all( + batches.map((batch) => getMultipleCardData(batch)) + ); + + const cardDataByCardId: Record = {}; + + cardDataInBatches.forEach((batch) => { + batch.data.forEach((card: CardData) => { + if (card.name) { + const cardId = nameToId(card.name); + cardDataByCardId[cardId] = card; + } + }); + }); + + return cardDataByCardId; }; \ No newline at end of file diff --git a/src/section-renderer.ts b/src/section-renderer.ts new file mode 100644 index 0000000..ab581a2 --- /dev/null +++ b/src/section-renderer.ts @@ -0,0 +1,380 @@ +import { CardCounts, nameToId, UNKNOWN_CARD } from "./collection"; +import { CardData } from "./scryfall"; +import { ObsidianPluginMtgSettings } from "./settings"; +import { Line } from "./types"; +import { currencyMapping, getCardPrice } from "./pricing"; +import { cardTypeIcons, cardTypeOrder, getTypeCounts, getTypeOrder, sortLines } from "./sorting"; +import { setupCardPreview } from "./card-preview"; +import { buildCardGrid, setupGridToggle } from "./card-grid"; +import { sanitizeHTMLToDom } from "obsidian"; + +export interface SectionRenderContext { + section: string; + lines: Line[]; + cardDataByCardId: Record; + hasCardInfo: boolean; + settings: ObsidianPluginMtgSettings; + missingCardCounts: CardCounts; + sectionTotalCounts: Record; + sectionTotalCost: Record; +} + +export const renderSection = ( + containerEl: HTMLElement, + ctx: SectionRenderContext +): HTMLElement => { + const { + section, + lines, + cardDataByCardId, + hasCardInfo, + settings, + missingCardCounts, + sectionTotalCounts, + sectionTotalCost, + } = ctx; + + const sectionContainer = containerEl.createDiv({ + cls: "decklist__section-container", + }); + + // Heading container with toggle button + const sectionHeadingContainer = sectionContainer.createDiv({ + cls: "decklist__section-heading-container", + }); + const sectionHeadingEl = sectionHeadingContainer.createEl("h3", { + cls: "decklist__section-heading", + }); + const toggleViewBtn = sectionHeadingContainer.createEl("button", { + text: "Visual View", + cls: "decklist__toggle-view", + }); + + const hasCards = lines.some(line => line.lineType === "card" && line.cardName); + toggleViewBtn.style.display = hasCards ? "" : "none"; + + // Card preview image container + const imgElContainer = sectionContainer.createDiv({ + cls: "card-image-container", + }); + const imgEl = imgElContainer.createEl("img", { cls: "card-image" }); + + // Handle "Name ..." metadata sections + if (section.startsWith("Name")) { + sectionHeadingEl.textContent = section.replace(/^Name\s+/, ""); + return sectionContainer; + } + + sectionHeadingEl.textContent = section; + + // Table + const sectionList = sectionContainer.createEl("table"); + const sectionListHead = sectionList.createEl("thead"); + const sectionListHeadRow = sectionListHead.createEl("tr"); + + sectionListHeadRow.createEl("th", { text: "Count" }); + sectionListHeadRow.createEl("th", { text: "Name", cls: "max" }); + + if (settings.decklist.showManaCosts) { + sectionListHeadRow.createEl("th", { text: "Cost" }); + } + if (settings.decklist.showCardPrices) { + sectionListHeadRow.createEl("th", { text: "Price" }); + } + + const sectionListBody = sectionList.createEl("tbody"); + const sectionMissingCardCounts: CardCounts = {}; + + // Card grid (visual view) + const cardGrid = buildCardGrid(sectionContainer, sortLines(lines, cardDataByCardId), cardDataByCardId); + setupGridToggle(toggleViewBtn, cardGrid, sectionList); + + // Sort and render lines + const sortedLines = sortLines(lines, cardDataByCardId); + let previousTypeOrder = -1; + + sortedLines.forEach((line: Line) => { + const lineEl = sectionListBody.createEl("tr"); + + if (line.lineType === "card") { + const cardId = nameToId(line.cardName!); + const cardInfo = cardDataByCardId[cardId]; + const currentTypeOrder = getTypeOrder(line, cardDataByCardId); + + if (currentTypeOrder !== previousTypeOrder) { + lineEl.classList.add("type-separator"); + previousTypeOrder = currentTypeOrder; + } + + // Count cell + const cardCountCell = lineEl.createEl("td"); + cardCountCell.createSpan({ cls: `card-rarity ${cardInfo?.rarity}` }); + const cardCountEl = cardCountCell.createSpan({ cls: "count" }); + + // Name cell + const cardNameCell = lineEl.createEl("td"); + const cardNameEl = cardNameCell.createSpan({ cls: "card-name" }); + const cardCommentsEl = cardNameCell.createSpan({ + cls: "comment", + text: line.comments?.join("#") || "", + }); + + // Mana cost cell + if (settings.decklist.showManaCosts) { + const cardCostCell = lineEl.createEl("td"); + const cardCostEl = cardCostCell.createSpan({ cls: "card-cost" }); + const cardManaCost = + cardInfo?.mana_cost ?? cardInfo?.card_faces?.[0]?.mana_cost; + + if (cardManaCost) { + cardManaCost + .replace(/\//g, "") + .split("{") + .slice(1) + .forEach((part) => { + cardCostEl.createEl("img", { + attr: { + src: `https://svgs.scryfall.io/card-symbols/${part.slice(0, -1)}.svg`, + width: 18, + height: 18, + }, + }); + }); + } + } + + // Price cell + let cardPrice: string | null = null; + let cardPriceEl: HTMLElement | undefined; + + if (settings.decklist.showCardPrices) { + const cardPriceCell = lineEl.createEl("td"); + cardPriceEl = cardPriceCell.createSpan({ cls: "card-price" }); + if (line.cardName) { + cardPrice = getCardPrice(line.cardName, cardDataByCardId, settings); + } + } + + // Card name / hyperlink + if (line.cardName) { + if (settings.decklist.showCardNamesAsHyperlinks && cardInfo?.scryfall_uri) { + const cardLinkEl = cardNameEl.createEl("a"); + cardLinkEl.href = cardInfo.scryfall_uri; + cardLinkEl.textContent = cardInfo.name ?? line.cardName; + } else { + cardNameEl.textContent = + cardInfo?.name ?? line.cardName ?? UNKNOWN_CARD; + } + } + + if (line.errors?.length) { + cardNameEl.createSpan({ + cls: "error", + text: line.errors.join(","), + }); + } + + const lineCardCount = line.cardCount || 0; + const lineGlobalCount = + line.globalCount === null ? -1 : line.globalCount || 0; + + if (lineGlobalCount !== -1 && lineCardCount > lineGlobalCount) { + // Insufficient count display + const counts = cardCountEl.createSpan({ cls: "count" }); + counts.createSpan({ cls: "error", text: `${lineGlobalCount}` }); + counts.createSpan({ text: ` / ${lineCardCount}` }); + lineEl.classList.add("insufficient-count"); + + missingCardCounts[cardId] = + (missingCardCounts[cardId] || 0) + (lineCardCount - lineGlobalCount); + sectionMissingCardCounts[cardId] = + (sectionMissingCardCounts[cardId] || 0) + + (lineCardCount - lineGlobalCount); + + if (cardPrice && cardPriceEl) { + cardPriceEl.classList.add("insufficient-count"); + const totalPrice = lineCardCount * parseFloat(cardPrice); + const amountOwned = lineGlobalCount * parseFloat(cardPrice); + const currency = currencyMapping[settings.decklist.preferredCurrency]; + + cardPriceEl.createSpan({ + cls: "error", + text: `${currency}${amountOwned.toFixed(2)}`, + }); + cardPriceEl.createSpan({ + text: ` / ${currency}${totalPrice.toFixed(2)}`, + }); + + sectionTotalCost[section] += totalPrice || 0; + } + } else { + cardCountEl.textContent = `${lineCardCount}`; + + if (cardPrice && cardPriceEl) { + const totalPrice = lineCardCount * parseFloat(cardPrice); + cardPriceEl.textContent = `${currencyMapping[settings.decklist.preferredCurrency]}${totalPrice.toFixed(2)}`; + sectionTotalCost[section] += totalPrice || 0; + } + } + + sectionTotalCounts[section] += line.cardCount || 0; + + // Card preview on hover + if (settings.decklist.showCardPreviews && line.cardName) { + setupCardPreview( + lineEl, + line.cardName, + cardDataByCardId, + imgElContainer, + imgEl, + sectionList + ); + } + } else if (line.lineType === "comment") { + lineEl.createSpan({ + cls: "comment", + text: line.comments?.join(" ") || "", + }); + } + }); + + // Footer + const sectionListFoot = sectionList.createEl("tfoot", { + cls: "decklist__section-totals", + }); + const sectionListFootRow = sectionListFoot.createEl("tr"); + const totalCardsEl = sectionListFootRow.createEl("td", { + cls: "decklist__section-totals__count", + }); + const totalTypeCardsEl = sectionListFootRow.createEl("td"); + + const typeCounts = getTypeCounts(lines, cardDataByCardId); + cardTypeOrder + .filter(type => typeCounts[type] > 0) + .forEach(type => { + const item = totalTypeCardsEl.createSpan({ cls: "type-summary__item" }); + const svgEl = sanitizeHTMLToDom(cardTypeIcons[type]); + item.appendChild(svgEl); + item.createSpan({ text: ` ${typeCounts[type]}` }); + }); + + sectionListFootRow.createEl("td"); + + let totalCostEl: HTMLElement | undefined; + if (hasCardInfo && settings.decklist.showCardPrices) { + totalCostEl = sectionListFootRow.createEl("td", { + cls: "decklist__section-totals__cost", + }); + } + + const sectionMissingCardIds = Object.keys(sectionMissingCardCounts); + + if (sectionMissingCardIds.length) { + const totalMissingCount = Object.values(sectionMissingCardCounts).reduce( + (acc, v) => acc + v, + 0 + ); + const totalCardsOwned = sectionTotalCounts[section] - totalMissingCount; + + totalCardsEl.createSpan({ cls: "error", text: `${totalCardsOwned}` }); + totalCardsEl.createSpan({ + cls: "insufficient-count", + text: ` / ${sectionTotalCounts[section]}`, + }); + + if (hasCardInfo && settings.decklist.showCardPrices && totalCostEl) { + const totalMissingCost = sectionMissingCardIds.reduce((acc, cardId) => { + const countNeeded = sectionMissingCardCounts[cardId]; + const price = parseFloat( + getCardPrice(cardId, cardDataByCardId, settings) || "0.00" + ); + return acc + price * countNeeded; + }, 0); + + const totalValueOwned = sectionTotalCost[section] - totalMissingCost; + const currency = currencyMapping[settings.decklist.preferredCurrency]; + + totalCostEl.createSpan({ + cls: "error", + text: `${currency}${totalValueOwned.toFixed(2)}`, + }); + totalCostEl.createSpan({ + cls: "insufficient-count", + text: ` / ${currency}${sectionTotalCost[section].toFixed(2)}`, + }); + } + } else { + totalCardsEl.textContent = `${sectionTotalCounts[section]}`; + if (settings.decklist.showCardPrices && totalCostEl) { + totalCostEl.textContent = `${currencyMapping[settings.decklist.preferredCurrency]}${sectionTotalCost[section].toFixed(2)}`; + } + } + + return sectionContainer; +}; + +export const renderSectionSkeleton = ( + containerEl: HTMLElement, + section: string, + lines: Line[], + settings: ObsidianPluginMtgSettings +): HTMLElement => { + const sectionContainer = containerEl.createDiv({ cls: "decklist__section-container" }); + + const sectionHeadingContainer = sectionContainer.createDiv({ cls: "decklist__section-heading-container" }); + const sectionHeadingEl = sectionHeadingContainer.createEl("h3", { cls: "decklist__section-heading" }); + sectionHeadingEl.textContent = section.startsWith("Name") + ? section.replace(/^Name\s+/, "") + : section; + + if (section.startsWith("Name")) { + return sectionContainer; + } + + const sectionList = sectionContainer.createEl("table"); + const sectionListHead = sectionList.createEl("thead"); + const sectionListHeadRow = sectionListHead.createEl("tr"); + + sectionListHeadRow.createEl("th", { text: "Count" }); + sectionListHeadRow.createEl("th", { text: "Name", cls: "max" }); + + if (settings.decklist.showManaCosts) { + sectionListHeadRow.createEl("th", { text: "Cost" }); + } + if (settings.decklist.showCardPrices) { + sectionListHeadRow.createEl("th", { text: "Price" }); + } + + const sectionListBody = sectionList.createEl("tbody"); + + lines.forEach((line: Line) => { + const lineEl = sectionListBody.createEl("tr"); + + if (line.lineType === "card") { + lineEl.setAttribute("data-card-name", line.cardName ?? ""); + + const cardCountCell = lineEl.createEl("td"); + cardCountCell.createSpan({ cls: "card-rarity" }); + cardCountCell.createSpan({ cls: "count", text: `${line.cardCount}` }); + + const cardNameCell = lineEl.createEl("td"); + cardNameCell.createSpan({ cls: "card-name", text: line.cardName ?? "" }); + cardNameCell.createSpan({ cls: "comment", text: line.comments?.join("#") || "" }); + + if (settings.decklist.showManaCosts) { + lineEl.createEl("td").createSpan({ cls: "card-cost" }); + } + if (settings.decklist.showCardPrices) { + lineEl.createEl("td").createSpan({ cls: "card-price" }); + } + } else if (line.lineType === "comment") { + lineEl.createSpan({ cls: "comment", text: line.comments?.join(" ") || "" }); + } + }); + + sectionList.createEl("tfoot", { cls: "decklist__section-totals" }) + .createEl("tr") + .createEl("td", { attr: { colspan: "4" } }); + + return sectionContainer; +}; \ No newline at end of file diff --git a/src/sorting.ts b/src/sorting.ts new file mode 100644 index 0000000..364802c --- /dev/null +++ b/src/sorting.ts @@ -0,0 +1,83 @@ +import { nameToId } from "./collection"; +import { CardData } from "./scryfall"; +import { Line } from "./types"; + +import creatureSvg from "./assets/card-types/creature.svg"; +import planeswalkerSvg from "./assets/card-types/planeswalker.svg"; +import sorcerySvg from "./assets/card-types/sorcery.svg"; +import instantSvg from "./assets/card-types/instant.svg"; +import artifactSvg from "./assets/card-types/artifact.svg"; +import enchantmentSvg from "./assets/card-types/enchantment.svg"; +import landSvg from "./assets/card-types/land.svg"; + +export const cardTypeOrder = [ + "Planeswalker", + "Creature", + "Sorcery", + "Instant", + "Artifact", + "Enchantment", + "Land", +]; + +export const cardTypeIcons: Record = { + "Planeswalker": planeswalkerSvg, + "Creature": creatureSvg, + "Sorcery": sorcerySvg, + "Instant": instantSvg, + "Artifact": artifactSvg, + "Enchantment": enchantmentSvg, + "Land": landSvg, +}; + +export const getTypeOrder = ( + line: Line, + cardDataByCardId: Record +): number => { + if (line.lineType !== "card" || !line.cardName) { + return 999; + } + + const cardId = nameToId(line.cardName); + const cardInfo = cardDataByCardId[cardId]; + + if (!cardInfo?.type_line) { + return 999; + } + + const index = cardTypeOrder.findIndex((type) => + cardInfo.type_line!.includes(type) + ); + + return index === -1 ? 999 : index; +}; + +export const getTypeCounts = ( + lines: Line[], + cardDataByCardId: Record +): Record => { + return lines + .filter(line => line.lineType === "card" && line.cardName) + .reduce((acc, line) => { + const typeOrder = getTypeOrder(line, cardDataByCardId); + const typeName = cardTypeOrder[typeOrder] ?? "Other"; + acc[typeName] = (acc[typeName] ?? 0) + (line.cardCount ?? 0); + return acc; + }, {} as Record); +}; + +export const sortLines = ( + lines: Line[], + cardDataByCardId: Record +): Line[] => { + return [...lines].sort((a, b) => { + const typeOrder = + getTypeOrder(a, cardDataByCardId) - getTypeOrder(b, cardDataByCardId); + + if (typeOrder !== 0) { + return typeOrder; + } + + return (a.cardName ?? "").localeCompare(b.cardName ?? ""); + }); +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b7d7a50 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,15 @@ +export const DEFAULT_SECTION_NAME = "Deck:"; +export const COMMENT_DELIMITER = "#"; +export const SKIP_SECTION_NAMES = ["About", "Name"]; + +export interface Line { + lineType: "card" | "section" | "error" | "blank" | "comment"; + cardCount?: number; + globalCount?: number | null; + cardName?: string; + cardSet?: string; + cardNumber?: string; + comments?: string[]; + errors?: string[]; + text?: string; +} diff --git a/styles.css b/styles.css index 92df4dd..42d5c59 100644 --- a/styles.css +++ b/styles.css @@ -28,8 +28,23 @@ border-top: 1px solid var(--background-modifier-border); } -.decklist__section-totals .card-name { - flex: 4; +.type-summary { + display: flex; + align-items: center; + gap: 8px; +} + +.type-summary__item { + display: inline-flex; + align-items: center; + gap: 3px; + white-space: nowrap; + margin-right: 5px; +} + +.type-summary__item svg { + vertical-align: middle; + position: relative; } /* Card */ diff --git a/svg.d.ts b/svg.d.ts new file mode 100644 index 0000000..6693335 --- /dev/null +++ b/svg.d.ts @@ -0,0 +1,4 @@ +declare module "*.svg" { + const content: string; + export default content; +} \ No newline at end of file diff --git a/versions.json b/versions.json index 201c309..37351a9 100644 --- a/versions.json +++ b/versions.json @@ -7,5 +7,6 @@ "1.5.52": "0.15.0", "1.6.54": "0.15.0", "1.7.56": "0.15.0", - "1.8.57": "0.15.0" + "1.8.57": "0.15.0", + "1.9.58": "0.15.0" } \ No newline at end of file