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 @@
+
+
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 @@
+
+
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 @@
+
+
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 @@
+
+
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 @@
+
+
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 @@
+
+
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 @@
+
+
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