Initial commit

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Samir L. Boulema 2026-04-30 19:51:30 +02:00
commit c9914e55ea
38 changed files with 11967 additions and 0 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

2
.eslintignore Normal file
View file

@ -0,0 +1,2 @@
npm node_modules
build

21
.eslintrc Normal file
View file

@ -0,0 +1,21 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

71
.github/workflows/workflow.yml vendored Normal file
View file

@ -0,0 +1,71 @@
name: MtG Deck
on:
push:
branches:
- main
- 'feature/**'
permissions:
contents: read
env:
version: '1.0.${{ github.run_number }}'
nodeVersion: '18'
jobs:
build:
name: 🛠️ Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.nodeVersion }}.x
- name: Build plugin
run: |
npm install
npm run build
- name: Publish Build Artifacts
uses: actions/upload-artifact@v7
with:
name: mtg-deck
path: |
main.js
manifest.json
styles.css
release:
if: github.ref_name == 'main'
name: 🚚 Release
needs: [build]
runs-on: ubuntu-latest
environment: Release
permissions:
contents: write
steps:
- name: Download artifact
uses: actions/download-artifact@v8
with:
name: mtg-deck
- name: Tag release
id: tag_release
uses: mathieudutour/github-tag-action@v6.2
with:
custom_tag: '${{ env.version }}'
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Create a GitHub release
uses: ncipollo/release-action@v1.21.0
with:
tag: ${{ steps.tag_release.outputs.new_tag }}
name: ${{ steps.tag_release.outputs.new_tag }}
body: ${{ steps.tag_release.outputs.changelog }}
artifacts: 'main.js,manifest.json,styles.css'
skipIfReleaseExists: true

22
.gitignore vendored Normal file
View file

@ -0,0 +1,22 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

8
.prettierignore Normal file
View file

@ -0,0 +1,8 @@
main.js
node_modules/
.husky
.circleci
jest/
jest.config.js
*.md
esbuild.config.mjs

1
.prettierrc.json Normal file
View file

@ -0,0 +1 @@
{}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Omar Delarosa & Samir Boulema
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

68
README.md Normal file
View file

@ -0,0 +1,68 @@
# Obsidian MTG Deck plugin
This is a plugin to manage your Magic: The Gathering card collections and decks as Obsidian notes.
## Decklists
Using the `mtg-deck` syntax hint in any Markdown file, you can define your decklists as follows:
```mtgdeck
4 Delver of Secrets // Insectile Aberration
4 Haughty Djinn
3 Tolarian Terror
4 Consider
4 Essence Scatter
4 Fading Hope
4 Make Disappear # consider Negate instead
4 Slip Out the Back
3 Spell Pierce
3 Thirst for Discovery
20 Island
1 Otawara, Soaring City
1 Otherworldly Gaze
1 Reckoner Bankbuster
Sideboard:
2 Disdainful Stroke
4 Negate
4 Out of the Way
1 Reckoner Bankbuster
4 Ertai's Scorn
```
Which in turn renders as:
![](art/example_decklist.png)
You can also copy paste directly from MTGA exports, though setlists and collector's numbers will not be shown and are not yet supported in the renderer.
## Collections
This plugin expects your collection to be stored as csv files with the extension `.mtg.collection.csv` by default. This extension is configurable in settings:
![](art/example_settings.png)
These files are expected to be properly formed CSVs such as those generated by tools like [Deckbox](https://deckbox.org/)
### Example CSV Files
```
Name,Count,Set
Delver of Secrets // Insectile Aberration,8,MID
"Otawara, Soaring City",4,NEO
"Rona's Vortex",2,DMU
```
```
Name,Count,Set
Delver of Secrets // Insectile Aberration,1,MID
"Otawara, Soaring City",6,NEO
"Rona's Vortex",3,DMU
Ledger Shredder,5,SNC
```
Note that your collection will consist of the merged result of all of your CSV files.
# Contributing
See [the official Obsidian plugin guidelines](https://github.com/obsidianmd/obsidian-sample-plugin#obsidian-sample-plugin)

BIN
art/example_decklist.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

BIN
art/example_settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

42
esbuild.config.mjs Normal file
View file

@ -0,0 +1,42 @@
import esbuild from "esbuild";
import process from "process";
import builtins from 'builtin-modules'
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === 'production');
esbuild.build({
banner: {
js: banner,
},
entryPoints: ['main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
...builtins],
format: 'cjs',
watch: !prod,
target: 'es2018',
logLevel: "info",
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
}).catch(() => process.exit(1));

8
jest.config.js Normal file
View file

@ -0,0 +1,8 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
// import {defaults} from 'jest-config';
module.exports = {
preset: 'ts-jest',
setupFiles: ['<rootDir>/jest/globalSetup.ts'],
testEnvironment: 'jest-environment-jsdom',
testRegex: '(/test/.*|(\\.|/)(test|spec))\\.[jt]sx?$'
};

63
jest/fixtures/content.ts Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

22
jest/globalSetup.ts Normal file
View file

@ -0,0 +1,22 @@
import * as util from 'util'
import { jest } from "@jest/globals";
// Mock http library to avoid using "obsidian" import
jest.mock<typeof import('../src/http')>('../src/http', () => {
return {
promiseWrappedRequest: (options: any) => Promise.resolve({} as any)
}
});
// ref: https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
// ref: https://github.com/jsdom/jsdom/issues/2524
Object.defineProperty(window, 'TextEncoder', {
writable: true,
value: util.TextEncoder
});
Object.defineProperty(window, 'TextDecoder', {
writable: true,
value: util.TextDecoder
});

232
main.ts Normal file
View file

@ -0,0 +1,232 @@
import { App, Plugin, PluginSettingTab, Setting } from "obsidian";
import {
DEFAULT_COLLECTION_COUNT_COLUMN,
DEFAULT_COLLECTION_FILE_EXTENSION,
DEFAULT_COLLECTION_NAME_COLUMN,
DEFAULT_COLLECTION_SYNC_INTERVAL,
syncCounts,
} from "src/collection";
import { renderDecklist } from "src/renderer";
import { ObsidianPluginMtgSettings } from "src/settings";
const DEFAULT_SETTINGS: ObsidianPluginMtgSettings = {
collection: {
fileExtension: DEFAULT_COLLECTION_FILE_EXTENSION,
nameColumn: DEFAULT_COLLECTION_NAME_COLUMN,
countColumn: DEFAULT_COLLECTION_COUNT_COLUMN,
syncIntervalMs: DEFAULT_COLLECTION_SYNC_INTERVAL,
},
decklist: {
preferredCurrency: "usd",
showCardNamesAsHyperlinks: true,
showCardPreviews: true,
showBuylist: true,
hidePrices: false,
},
};
export default class ObsidianPluginMtg extends Plugin {
settings: ObsidianPluginMtgSettings;
// This keeps a record of the collection in memory
cardCounts: Record<string, number>;
async onload() {
await this.loadSettings();
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new ObsidianPluginMtgSettingsTab(this.app, this));
const { vault } = this.app;
vault.on("modify", async (f) => {
if (f.name.endsWith(".csv")) {
const settings = this.settings;
const collectionFileExt =
settings.collection?.fileExtension || "";
if (f.name.endsWith(collectionFileExt)) {
this.cardCounts = await syncCounts(vault, settings);
}
}
});
this.app.workspace.onLayoutReady(async () => {
this.cardCounts = await syncCounts(vault, this.settings);
});
this.registerMarkdownCodeBlockProcessor(
"mtg-deck",
async (source: string, el: HTMLElement, ctx) => {
let error = null;
// Sync card counts once if they haven't been already
if (!this.cardCounts) {
this.cardCounts = await syncCounts(vault, this.settings);
}
try {
await renderDecklist(
el,
source,
this.cardCounts,
this.settings
);
} catch (err) {
error = err;
console.log(err);
const errorNode = document.createDiv({
text: error,
cls: "obsidian-plugin-mtg-error",
});
el.appendChild(errorNode);
}
}
);
}
onunload() {}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
);
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class ObsidianPluginMtgSettingsTab extends PluginSettingTab {
plugin: ObsidianPluginMtg;
constructor(app: App, plugin: ObsidianPluginMtg) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", {
text: "Settings for Obsidian MtG Plugin.",
});
// Collection CSV setting
new Setting(containerEl)
.setName("Collection CSV")
.setDesc("The file extension of your collection as a CSV file")
.addText((text) =>
text
.setPlaceholder(".mtg.collection.csv")
.setValue(this.plugin.settings.collection.fileExtension)
.onChange(async (value) => {
this.plugin.settings.collection.fileExtension = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Card name column name")
.setDesc("The name of the CSV column used for card names")
.addText((text) =>
text
.setPlaceholder("Name")
.setValue(this.plugin.settings.collection.nameColumn)
.onChange(async (value) => {
this.plugin.settings.collection.nameColumn = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Card count column name")
.setDesc("The name of the CSV column used for card counts/quantity")
.addText((text) =>
text
.setPlaceholder("Count")
.setValue(this.plugin.settings.collection.countColumn)
.onChange(async (value) => {
this.plugin.settings.collection.countColumn = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Preferred Currency")
.setDesc(
"The currency you prefer when viewing card prices in your decklist"
)
.addDropdown((dropdown) =>
dropdown
.addOption("usd", "USD")
.addOption("eur", "EUR")
.addOption("tix", "Tix")
.onChange(async (value: "usd" | "eur" | "tix") => {
this.plugin.settings.decklist.preferredCurrency = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Show Card Name Hyperlinks")
.setDesc(
"Enables card names that link to Scryfall or purchasing sites when possible"
)
.addToggle((toggle) =>
toggle
.setValue(
this.plugin.settings.decklist.showCardNamesAsHyperlinks
)
.onChange(async (value: boolean) => {
this.plugin.settings.decklist.showCardNamesAsHyperlinks =
value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Show Card Images")
.setDesc(
"Enables card previews when hovering with the mouse on desktop"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.decklist.showCardPreviews)
.onChange(async (value: boolean) => {
this.plugin.settings.decklist.showCardPreviews = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Show Buylist")
.setDesc(
"Enables a buylist below your decklist with buylinks for each card"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.decklist.showBuylist)
.onChange(async (value: boolean) => {
this.plugin.settings.decklist.showBuylist = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Hide Prices")
.setDesc("Toggles card prices in decklists")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.decklist.hidePrices)
.onChange(async (value: boolean) => {
this.plugin.settings.decklist.hidePrices = value;
await this.plugin.saveSettings();
})
);
}
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "mtg-deck",
"name": "MtG Deck",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "A plugin for managing Magic: The Gathering decks and card lists as Obsidian notes",
"author": "Samir Boulema",
"authorUrl": "https://github.com/sboulema",
"isDesktopOnly": false
}

6756
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

42
package.json Normal file
View file

@ -0,0 +1,42 @@
{
"name": "mtg-deck",
"version": "1.0.0",
"description": "A plugin for managing Magic: The Gathering decks and card lists as Obsidian notes",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"lint": "eslint --fix main.ts",
"format": "npx prettier --write . ",
"format-check": "npx prettier --check .",
"test": "jest",
"verify": "npm run test && npm run lint && npm run format-check",
"prepare": "husky install",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"engines": {
"node": "16.17.1"
},
"keywords": [],
"author": "Samir Boulema",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.14.47",
"eslint": "^8.24.0",
"jest": "^29.1.2",
"jest-environment-jsdom": "^29.1.2",
"jsdom": "^20.0.1",
"obsidian": "latest",
"prettier": "2.7.1",
"prettier-eslint": "^15.0.1",
"ts-jest": "^29.0.3",
"ts-node": "^10.9.1",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {}
}

13
register.js Normal file
View file

@ -0,0 +1,13 @@
/**
* Overrides the tsconfig used for the app.
* In the test environment we need some tweaks.
*/
const tsNode = require("ts-node");
const testTSConfig = require("./test/tsconfig.json");
tsNode.register({
files: true,
transpileOnly: true,
project: "./test/tsconfig.json",
});

78
src/collection.spec.ts Normal file
View file

@ -0,0 +1,78 @@
import { describe, expect, test } from "@jest/globals";
import { createCardCountsMapping, nameToId } from "./collection";
import { ObsidianPluginMtgSettings } from "./settings";
// Basic example
const EXAMPLE_CSV_1 = `Name,Count,Set
Delver of Secrets // Insectile Aberration,8,MID
"Otawara, Soaring City",4,NEO
"Rona's Vortex",2,DMU
`;
// With some overlapping cards
const EXAMPLE_CSV_2 = `Name,Count,Set
Delver of Secrets // Insectile Aberration,1,MID
"Otawara, Soaring City",6,NEO
"Rona's Vortex",3,DMU
Ledger Shredder,5,SNC
`;
// Empty
const EXAMPLE_CSV_3 = `Name,Count,Set`;
describe("Collection", () => {
describe("#nameToId()", () => {
test("handles split cards", () => {
const result = nameToId(
"Delver of Secrets // Insectile Aberration"
);
expect(result).toEqual("delver of secrets");
});
test("handles spacing around name", () => {
const result = nameToId(" Black Lotus ");
expect(result).toEqual("black lotus");
});
test("handles commas", () => {
const result = nameToId("Otawara, Soaring City");
expect(result).toEqual("otawara, soaring city");
});
test("handles apostrophes", () => {
const result = nameToId("Rona's Vortex");
expect(result).toEqual("rona's vortex");
});
});
describe("#createCardCountsMapping", () => {
const settings: ObsidianPluginMtgSettings = {
collection: {
fileExtension: "mtg.collection.csv",
nameColumn: "Name",
countColumn: "Count",
syncIntervalMs: 10,
},
decklist: {
preferredCurrency: "usd",
showCardNamesAsHyperlinks: true,
showCardPreviews: true,
showBuylist: true,
hidePrices: false,
},
};
test("handles multiple CSVs", async () => {
const contents = [EXAMPLE_CSV_1, EXAMPLE_CSV_2];
const mapping = await createCardCountsMapping(contents, settings);
expect(mapping).toEqual({
"delver of secrets": 9,
"otawara, soaring city": 10,
"rona's vortex": 5,
"ledger shredder": 5,
});
});
});
});

104
src/collection.ts Normal file
View file

@ -0,0 +1,104 @@
import { Vault } from "obsidian";
import { parseCsvFile } from "./csv";
import { ObsidianPluginMtgSettings } from "./settings";
export type CardCounts = Record<string, number>;
export const DEFAULT_COLLECTION_FILE_EXTENSION = "mtg.collection.csv";
export const DEFAULT_COLLECTION_NAME_COLUMN = "Name";
export const DEFAULT_COLLECTION_COUNT_COLUMN = "Count";
export const DEFAULT_COLLECTION_SYNC_INTERVAL = 5000;
export const UNKNOWN_CARD = "UNKNOWN_CARD";
export const nameToId = (rawName: string | undefined) => {
return (
(rawName || "")
// handle double-faced cards (i.e. "Delver Of Secrets" and "Delver of Secrets // Insectile Aberration")
.split("//")[0]
// remove surrounding whitespace
.trim()
// normalizing casing
.toLowerCase()
);
};
export const createCardCountsMapping = async (
fileContents: string[],
settings: ObsidianPluginMtgSettings
) => {
const counts: CardCounts = {};
const countsColumnName: string = settings.collection.countColumn;
const nameColumnName: string = settings.collection.nameColumn;
const recordsList: Record<string, string>[][] = fileContents.map(
(fileContent) => {
const records = parseCsvFile(fileContent, {
skip_empty_lines: true,
});
return records;
}
);
recordsList.forEach((records) => {
records.forEach((record) => {
const count: number = parseInt(record[countsColumnName] || "0");
const cardName: string =
nameToId(record[nameColumnName]) || UNKNOWN_CARD;
if (!counts[cardName]) {
counts[cardName] = count;
} else {
counts[cardName] = counts[cardName] + count;
}
});
});
return counts;
};
export const processCollectionFiles = async (
vault: Vault,
settings: ObsidianPluginMtgSettings
): Promise<string[]> => {
return (
(
await Promise.all(
vault
.getFiles()
.filter((f) => {
if (f.extension === "csv") {
return f.name.endsWith(
`${settings.collection.fileExtension}`
);
} else {
return false;
}
})
.map((fileContents) => {
try {
return vault.cachedRead(fileContents);
} catch (err) {
return "";
}
})
)
)
// remove any unreadable files
.filter((s) => s.length)
);
};
export const syncCounts = async (
vault: Vault,
settings: ObsidianPluginMtgSettings
): Promise<CardCounts> => {
// Sync Collection
const collectionContents: string[] = await processCollectionFiles(
vault,
settings
);
// Create consolidationed collection dictionary
return createCardCountsMapping(collectionContents, settings);
};

27
src/csv.spec.ts Normal file
View file

@ -0,0 +1,27 @@
import { describe, expect, test } from "@jest/globals";
import { removeOuterQuotesFromString } from "./csv";
describe("CSV Parser", () => {
describe("#removeOuterQuotesFromString()", () => {
test("it handles empty string", () => {
expect(removeOuterQuotesFromString("")).toEqual("");
});
test("it handles a short string", () => {
expect(removeOuterQuotesFromString("a")).toEqual("a");
});
test("it handles a single quote inside other quotes", () => {
expect(removeOuterQuotesFromString('"')).toEqual('"');
});
test("it handles a string inside quotes", () => {
expect(removeOuterQuotesFromString('"foo"')).toEqual("foo");
});
test("it handles mismatched quotes", () => {
expect(removeOuterQuotesFromString('"foo')).toEqual('"foo');
});
test("it handles nested quotes", () => {
expect(removeOuterQuotesFromString('""foo" and "foo""')).toEqual(
'"foo" and "foo"'
);
});
});
});

94
src/csv.ts Normal file
View file

@ -0,0 +1,94 @@
/**
* Adapted via answer from StackOverflow:
*
* https://stackoverflow.com/questions/1293147/how-to-parse-csv-data
*
*/
export const parseCsvCells = (str: string): string[][] => {
const arr: string[][] = [];
var quote = false; // 'true' means we're inside a quoted field
// Iterate over each character, keep track of current row and column (of the returned array)
for (var row = 0, col = 0, c = 0; c < str.length; c++) {
var cc = str[c],
nc = str[c + 1]; // Current character, next character
arr[row] = arr[row] || []; // Create a new row if necessary
arr[row][col] = arr[row][col] || ""; // Create a new column (start with empty string) if necessary
// If the current character is a quotation mark, and we're inside a
// quoted field, and the next character is also a quotation mark,
// add a quotation mark to the current column and skip the next character
if (cc == '"' && quote && nc == '"') {
arr[row][col] += cc;
++c;
continue;
}
// If it's just one quotation mark, begin/end quoted field
if (cc == '"') {
quote = !quote;
continue;
}
// If it's a comma and we're not in a quoted field, move on to the next column
if (cc == "," && !quote) {
++col;
continue;
}
// If it's a newline (CRLF) and we're not in a quoted field, skip the next character
// and move on to the next row and move to column 0 of that new row
if (cc == "\r" && nc == "\n" && !quote) {
++row;
col = 0;
++c;
continue;
}
// If it's a newline (LF or CR) and we're not in a quoted field,
// move on to the next row and move to column 0 of that new row
if (cc == "\n" && !quote) {
++row;
col = 0;
continue;
}
if (cc == "\r" && !quote) {
++row;
col = 0;
continue;
}
// Otherwise, append the current character to the current column
arr[row][col] += cc;
}
return arr;
};
export const parseCsvFile = (
fileContent: string,
opts?: { skip_empty_lines?: boolean }
): Record<string, string>[] => {
// Assumes that there is a columns header
const lines = fileContent.split("\n");
const headerRow = lines[0];
const columnNames = headerRow.split(",").map(removeOuterQuotesFromString);
const linesOfCells = parseCsvCells(fileContent);
// Attach the header names to each row
return linesOfCells.splice(1).map((cells) => {
const obj: Record<string, string> = {};
if (cells.length !== 0 || cells.length !== 0) {
columnNames.forEach((columnName: string, idx: number) => {
obj[columnName] = cells[idx];
});
}
return obj;
});
};
export const removeOuterQuotesFromString = (s: string) => {
if (s.length > 2 && s[0] === '"' && s[s.length - 1] === '"') {
return s.slice(1, s.length - 1);
}
return s;
};

65
src/dom-utils.ts Normal file
View file

@ -0,0 +1,65 @@
export function createSpan(
root: Element,
params?: { cls?: string | string[]; text?: string }
): HTMLSpanElement {
// Use native method when available
if (root.createSpan) {
return root.createSpan(params);
}
// Otherwise, fallback to this extension
const el = document.createElement("span");
if (!params || Object.keys(params).length === 0) {
return el;
}
const { cls, text } = params;
if (Array.isArray(cls)) {
cls.forEach((c) => el.classList.add(c));
} else {
if (cls) {
el.classList.add(cls);
}
}
if (text) {
el.textContent = text;
}
root.appendChild(el as Node);
return el;
}
export function createDiv(
root: Element,
params?: { cls?: string | string[]; text?: string }
): HTMLDivElement {
if (root.createDiv) {
return root.createDiv(params);
}
const el = document.createElement("div");
if (!params || Object.keys(params).length === 0) {
return el;
}
const { cls, text } = params;
if (Array.isArray(cls)) {
cls.forEach((c) => el.classList.add(c));
} else {
if (cls) {
el.classList.add(cls);
}
}
if (text) {
el.textContent = text;
}
root.appendChild(el);
return el;
}

26
src/http.ts Normal file
View file

@ -0,0 +1,26 @@
import { requestUrl } from "obsidian";
export interface RequestOptions {
url: string;
method?: string;
body?: string;
contentType?: string;
throw?: boolean;
headers?: Record<string, string>;
}
export type Request = <T>(options: RequestOptions) => Promise<T>;
export function promiseWrappedRequest<T>(options: RequestOptions): Promise<T> {
return new Promise(async (resolve, reject) => {
const response = await requestUrl(options);
if (response.status < 400) {
const scryfallData = response.json as T;
resolve(scryfallData);
} else {
reject(
new Error(`RequestError: ${response.status}: ${response.text}`)
);
}
});
}

72
src/renderer.spec.ts Normal file
View file

@ -0,0 +1,72 @@
import { describe, expect, test } from "@jest/globals";
import { renderDecklist } from "./renderer";
import { JSDOM } from "jsdom";
import { ObsidianPluginMtgSettings } from "./settings";
import { EXAMPLE_DECKLIST_CARD_DATA } from "../jest/fixtures/scryfall-data";
import { CardData } from "./scryfall";
import {
EXAMPLE_COLLECTION,
EXAMPLE_DECK_1,
EXAMPLE_DECK_1_HTML,
EXAMPLE_DECK_1_HTML_WITHOUT_PRICES,
} from "../jest/fixtures/content";
const dom = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
const doc = dom.window.document;
const fakeFetcher = (
distinctCardNames: string[]
): Promise<Record<string, CardData>> =>
new Promise((resolve, reject) => {
resolve(EXAMPLE_DECKLIST_CARD_DATA as Record<string, CardData>);
});
describe("Renderer", () => {
const settings: ObsidianPluginMtgSettings = {
collection: {
fileExtension: "mtg.collection.csv",
nameColumn: "Name",
countColumn: "Count",
syncIntervalMs: 10,
},
decklist: {
preferredCurrency: "usd",
showCardNamesAsHyperlinks: true,
showCardPreviews: true,
showBuylist: true,
hidePrices: false,
},
};
describe("#renderDecklist", () => {
test("with prices", async () => {
const el = await renderDecklist(
doc.body,
EXAMPLE_DECK_1,
EXAMPLE_COLLECTION,
settings,
fakeFetcher
);
expect(el.innerHTML.trim()).toEqual(EXAMPLE_DECK_1_HTML.trim());
});
test("witout prices", async () => {
const el = await renderDecklist(
doc.body,
EXAMPLE_DECK_1,
EXAMPLE_COLLECTION,
{
...settings,
decklist: {
...settings.decklist,
hidePrices: true,
},
},
fakeFetcher
);
expect(el.innerHTML.trim()).toEqual(
EXAMPLE_DECK_1_HTML_WITHOUT_PRICES.trim()
);
});
});
});

683
src/renderer.ts Normal file
View file

@ -0,0 +1,683 @@
import { CardCounts, nameToId, UNKNOWN_CARD } from "./collection";
import {
CardData,
getMultipleCardData,
MAX_SCRYFALL_BATCH_SIZE,
ScryfallResponse,
} from "./scryfall";
import { ObsidianPluginMtgSettings } from "./settings";
import { createDiv, createSpan } from "./dom-utils";
const DEFAULT_SECTION_NAME = "Deck:";
const COMMENT_DELIMITER = "#";
interface Line {
lineType: "card" | "section" | "error" | "blank" | "comment";
cardCount?: number;
globalCount?: number | null;
cardName?: string;
comments?: string[];
errors?: string[];
text?: string;
}
const lineMatchRE = /(\d+)\s(.*)/;
const setCodesRE = /(\([A-Za-z0-9]{3}\)\s\d+)/;
const lineWithSetCodes = /(\d+)\s+([\w| ,']*)\s+(\([A-Za-z0-9]{3}\)\s\d+)/;
const blankLineRE = /^\s+$/;
const headingMatchRE = new RegExp("^[^[0-9|" + COMMENT_DELIMITER + "]");
const currencyMapping = {
usd: "$",
eur: "€",
tix: "Tx",
};
const idToNameMemo: Record<string, string> = {};
export const getCardPrice = (
cardName: string,
cardDataById: Record<string, CardData>,
settings: ObsidianPluginMtgSettings
) => {
const cardId = nameToId(cardName);
const cardData = cardDataById[cardId];
const preferredCurrency = settings.decklist.preferredCurrency;
const hidePrices = settings.decklist.hidePrices;
if (!cardData || hidePrices) {
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
let 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 setcodes, etc
if (lineWithoutComments.match(lineWithSetCodes)) {
lineWithoutComments = lineWithoutComments
.replace(setCodesRE, "")
.trim();
}
// 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
let 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 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,
comments,
errors,
};
}
});
};
export const buildDistinctCardNamesList = (lines: Line[]): string[] => {
return Array.from(
new Set(
lines
.map((line) => line.cardName || "")
// Remove missing values
.filter((line) => line !== "")
)
);
};
export const fetchCardDataFromScryfall = async (
distinctCardNames: string[]
): Promise<Record<string, CardData>> => {
// Fetch in batches of 75, since that's the limit of Scryfall batch sizes
const batches: string[][] = [];
let currentBatch: string[] = [];
batches.push(currentBatch);
distinctCardNames.forEach((cardName: string, idx: number) => {
if (currentBatch.length === MAX_SCRYFALL_BATCH_SIZE) {
batches.push(currentBatch);
// Make new batch
currentBatch = [];
}
currentBatch.push(nameToId(cardName));
});
// Add remaining cards
batches.push(currentBatch);
const cardDataInBatches: ScryfallResponse[] = await Promise.all(
batches.map((batch) => getMultipleCardData(batch))
);
const cardDataByCardId: Record<string, CardData> = {};
const cards = [];
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 const renderDecklist = async (
root: Element,
source: string,
cardCounts: CardCounts,
settings: ObsidianPluginMtgSettings,
dataFetcher = fetchCardDataFromScryfall
): Promise<Element> => {
const containerEl = createDiv(root, {});
containerEl.classList.add("decklist");
const lines: string[] = source.split("\n");
const parsedLines: Line[] = parseLines(lines, cardCounts);
const linesBySection: Record<string, Line[]> = {};
let currentSection = DEFAULT_SECTION_NAME;
let sections: string[] = [];
// A reverse mapping for getting names from an id
const idsToNames: Record<string, 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 card names
const distinctCardNames: string[] = buildDistinctCardNamesList(parsedLines);
let cardDataByCardId: Record<string, CardData> = {};
// Try to fetch data from Scryfall
try {
cardDataByCardId = await dataFetcher(distinctCardNames);
} catch (err) {
console.log("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[] = [];
// Header section
const header = createDiv(containerEl, {
cls: "header",
});
const imgElContainer = document.createElement("div");
imgElContainer.classList.add("card-image-container");
const imgEl = document.createElement("img");
imgEl.classList.add("card-image");
imgElContainer.appendChild(imgEl);
// Attach image container to header
header.appendChild(imgElContainer);
// Footer Section
const footer = document.createElement("div");
footer.classList.add("footer");
const sectionTotalCounts: Record<string, number> = sections.reduce(
(acc, curr) => ({ ...acc, [curr]: 0 }),
{}
);
const sectionTotalCost: Record<string, number> = sections.reduce(
(acc, curr) => ({ ...acc, [curr]: 0.0 }),
{}
);
const missingCardCounts: CardCounts = {};
sections.forEach((section: string) => {
// Put the entire deck in containing div for styling
const sectionContainer = document.createElement("div");
sectionContainer.classList.add("decklist__section-container");
// Create a heading
const sectionHedingEl = document.createElement("h3");
sectionHedingEl.classList.add("decklist__section-heading");
sectionContainer.appendChild(sectionHedingEl);
// Create container for the list items
const sectionList = document.createElement("ul");
sectionList.classList.add("decklist__section-list");
const sectionMissingCardCounts: CardCounts = {};
// Create line item elements
linesBySection[section].forEach((line: Line) => {
const lineEl = document.createElement("li");
lineEl.classList.add("decklist__section-list-item");
if (line.lineType === "card") {
const cardCountEl = createSpan(lineEl, {
cls: "count",
});
const cardNameEl = createSpan(lineEl, {
cls: "card-name",
});
// 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 = document.createElement("a");
const purchaseUri = cardInfo.scryfall_uri;
cardLinkEl.href = purchaseUri;
cardLinkEl.textContent = `${cardInfo.name}`;
cardNameEl.appendChild(cardLinkEl);
} else {
cardNameEl.textContent = `${
(cardInfo && cardInfo.name) ||
line.cardName ||
UNKNOWN_CARD
}`;
}
}
let cardErrorsEl = null;
if (line.errors && line.errors.length) {
cardErrorsEl = createSpan(lineEl, {
cls: "error",
text: line.errors?.join(",") || "",
});
}
const cardCommentsEl = createSpan(lineEl, {
cls: "comment",
text: line.comments?.join("#") || "",
});
const cardPriceEl = createSpan(lineEl, {
cls: "card-price",
});
let cardPrice;
if (line.cardName) {
cardPrice = getCardPrice(
line.cardName,
cardDataByCardId,
settings
);
}
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 = createSpan(cardCountEl);
// Card error element
createSpan(counts, {
cls: "error",
text: `${lineGlobalCount}`,
});
// Card counts row element
createSpan(counts, {
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);
const amountOwnedEl = createSpan(cardPriceEl, {
cls: "error",
text: `${
currencyMapping[
settings.decklist.preferredCurrency
]
}${amountOwned.toFixed(2)}`,
});
// totalPriceEl
createSpan(cardPriceEl, {
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);
if (cardErrorsEl) {
lineEl.appendChild(cardErrorsEl);
}
if (settings.decklist.showCardPreviews) {
// Event handlers for card artwork popover
lineEl.addEventListener("mouseenter", () => {
const cardId = nameToId(line.cardName);
const cardInfo = cardDataByCardId[cardId];
let imgUri: string | undefined;
if (cardInfo) {
// For single-faced cards...
if (cardInfo.image_uris) {
imgUri = cardInfo.image_uris?.large;
// For double-faced cards...
} else if (
cardInfo.card_faces &&
cardInfo.card_faces.length > 1
) {
// Use the front-side of the card for preview
imgUri =
cardInfo.card_faces[0].image_uris?.large;
}
const offsetPaddingTop = 16;
imgElContainer.style.top = `${
lineEl.offsetTop + offsetPaddingTop
}px`;
imgElContainer.style.left = `${cardCommentsEl.offsetLeft}px`;
}
if (typeof imgUri !== "undefined") {
imgEl.src = imgUri;
}
});
lineEl.addEventListener("mouseleave", () => {
imgEl.src = "";
});
}
sectionList.appendChild(lineEl);
} else if (line.lineType === "comment") {
// Comments
createSpan(lineEl, {
cls: "comment",
text: line.comments?.join(" ") || "",
});
sectionList.appendChild(lineEl);
}
});
sectionHedingEl.textContent = `${section}`;
sectionContainer.appendChild(sectionList);
const horizontalDividorEl = document.createElement("hr");
sectionContainer.appendChild(horizontalDividorEl);
const totalsEl = createDiv(sectionContainer, {
cls: "decklist__section-totals",
});
const sectionMissingCardIds = Object.keys(sectionMissingCardCounts);
const totalCardsEl = createSpan(sectionContainer);
const totalCostEl = createSpan(sectionContainer);
// 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
createSpan(totalCardsEl, {
cls: "error",
text: `${totalCardsOwned}`,
});
// Counts
createSpan(totalCardsEl, {
cls: "insufficient-count",
text: ` / ${sectionTotalCounts[section]}`,
});
totalCardsEl.classList.add("decklist__section-totals__count");
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.hidePrices) {
const totalValueOwned =
sectionTotalCost[section] - totalMissingCostInSection;
const totalValueOwnedEl = createSpan(totalCostEl, {
cls: "error",
text: `${
currencyMapping[settings.decklist.preferredCurrency]
}${totalValueOwned.toFixed(2)}`,
});
// Total value needed
createSpan(totalCostEl, {
cls: "insufficient-count",
text: ` / ${
currencyMapping[settings.decklist.preferredCurrency]
}${sectionTotalCost[section].toFixed(2)}`,
});
}
// Otherwise show simple values
} else {
totalCardsEl.classList.add("decklist__section-totals__count");
totalCardsEl.textContent = `${sectionTotalCounts[section]}`;
if (!settings.decklist.hidePrices) {
totalCostEl.textContent = `${
currencyMapping[settings.decklist.preferredCurrency]
}${sectionTotalCost[section].toFixed(2)}`;
}
}
totalsEl.appendChild(totalCardsEl);
const totalCardsUnitEl = createSpan(totalsEl, {
cls: "card-name",
text: "cards",
});
if (hasCardInfo && !settings.decklist.hidePrices) {
totalsEl.appendChild(totalCostEl);
}
sectionContainer.appendChild(totalsEl);
sectionContainers.push(sectionContainer);
});
sectionContainers.forEach((sectionContainer) =>
containerEl.appendChild(sectionContainer)
);
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 = document.createElement("div");
buylist.classList.add("buylist-container");
const buylistHeader = document.createElement("h3");
buylistHeader.classList.add("decklist__section-heading");
buylistHeader.textContent = "Buylist: ";
buylist.appendChild(buylistHeader);
let totalCostOfBuylist = 0.0;
let buylistLines = "";
buylistCardIds.forEach((cardId) => {
const cardInfo = cardDataByCardId[cardId];
let buylistLine = "";
const countNeeded = missingCardCounts[cardId];
// const countEl = createSpan(buylistLineEl, {
// cls: "decklist__section-totals__count",
// text: `${countNeeded}`,
// });
// Add count
buylistLine += `${countNeeded}` + " ";
if (cardInfo) {
const cardName = cardInfo.name || "";
buylistLine += `${cardName}`;
// Retrieve price
const cardPrice: number = parseFloat(
getCardPrice(cardName, cardDataByCardId, settings) || "0.00"
);
totalCostOfBuylist =
totalCostOfBuylist + cardPrice * countNeeded;
buylistLines += buylistLine + "\n";
} else {
// Card name might be unknown
buylistLines += buylistLine + `${cardId || UNKNOWN_CARD}\n`;
}
});
const buylistPre = document.createElement("pre");
buylistPre.classList.add("buylist-container");
buylistPre.textContent = buylistLines;
buylist.appendChild(buylistPre);
const horizontalDividorEl = document.createElement("hr");
buylist.appendChild(horizontalDividorEl);
const buylistLineEl = document.createElement("div");
buylistLineEl.classList.add("buylist-line");
// countEl
createSpan(buylistLineEl, {
cls: "decklist__section-totals__count",
text: `${buylistCardCounts} `,
});
// cardNameEl
createSpan(buylistLineEl, {
cls: "card-name",
text: "cards",
});
let totalPriceEl = null;
if (hasCardInfo && !settings.decklist.hidePrices) {
totalPriceEl = createSpan(buylistLineEl, {
cls: "decklist__section-totals",
text: `${
currencyMapping[settings.decklist.preferredCurrency]
}${totalCostOfBuylist.toFixed(2)}`,
});
}
buylist.appendChild(buylistLineEl);
footer.appendChild(buylist);
}
containerEl.appendChild(footer);
return containerEl;
};

44
src/scryfall.spec.ts Normal file
View file

@ -0,0 +1,44 @@
import { describe, expect, test, jest } from "@jest/globals";
import { getCardData, getMultipleCardData, RequestOptions } from "./scryfall";
import {
EXAMPLE_MULTI_CARD_RESPONSE,
EXAMPLE_SCRYFALL_RESPONSE_1,
} from "../jest/fixtures/scryfall-data";
describe("Scryfall", () => {
describe("#getCardData()", () => {
test("for a single card", async () => {
function httpReq<ScryfallResponse>(
options: RequestOptions
): Promise<ScryfallResponse> {
return new Promise((res) => {
res(
EXAMPLE_SCRYFALL_RESPONSE_1 as unknown as ScryfallResponse
);
});
}
const data = await getCardData("Delver of Secrets", httpReq);
expect(data).toEqual(EXAMPLE_SCRYFALL_RESPONSE_1);
});
});
describe("#getMultipleCardData()", () => {
test("for a single card", async () => {
function httpReq<ScryfallResponse>(
options: RequestOptions
): Promise<ScryfallResponse> {
return new Promise((res) => {
res(
EXAMPLE_MULTI_CARD_RESPONSE as unknown as ScryfallResponse
);
});
}
const data = await getMultipleCardData(
["Delver of Secrets", "Ledger Shredder", "Dark Ritual"],
httpReq
);
expect(data).toEqual(EXAMPLE_MULTI_CARD_RESPONSE);
});
});
});

189
src/scryfall.ts Normal file
View file

@ -0,0 +1,189 @@
import { promiseWrappedRequest } from "./http";
const querystring = require("querystring");
export interface RequestOptions {
url: string;
method?: string;
body?: string;
contentType?: string;
throw?: boolean;
headers?: Record<string, string>;
}
export type Request = <T>(options: RequestOptions) => Promise<T>;
// This is the maximum number of cards that can be requested at the same time
export const MAX_SCRYFALL_BATCH_SIZE = 75;
export interface CardFace {
object?: string; // card_face
name?: string;
mana_cost?: string;
type_line?: string;
oracle_text?: string;
colors?: string[];
power?: string;
toughness?: string;
flavor_text?: string;
flavor_name?: string;
color_indicator?: string[];
artist?: string;
artist_id?: string;
illustration_id?: string;
image_uris?: {
small: string;
normal: string;
large: string;
png: string;
art_crop: string;
border_crop: string;
};
}
export interface CardData {
object?: string; // card
id?: string;
oracle_id?: string;
multiverse_ids?: number[];
mtgo_id?: number;
arena_id?: number;
tcgplayer_id?: number;
cardmarket_id?: number;
name?: string;
lang?: string;
released_at?: string;
uri?: string;
scryfall_uri?: string;
layout?: string;
highres_image?: boolean;
image_status?: string;
cmc?: number;
type_line?: string;
color_identity?: string[];
keywords?: string[];
card_faces?: CardFace[];
legalities?: Record<string, string>;
games?: string[];
reserved?: boolean;
foil?: boolean;
nonfoil?: boolean;
finishes?: string[];
oversized?: boolean;
promo?: boolean;
reprint?: boolean;
variation?: boolean;
set_id?: string;
set: string;
set_name: string;
set_type: string;
set_uri: string;
set_search_uri: string;
scryfall_set_uri: string;
rulings_uri: string;
prints_search_uri: string;
collector_number: string;
digital: boolean;
rarity: string;
artist: string;
artist_ids: string[];
border_color: string;
frame?: string;
frame_effects?: string[];
full_art?: boolean;
textless?: boolean;
booster?: boolean;
image_uris?: {
art_crop?: string;
border_crop?: string;
large?: string;
normal?: string;
png?: string;
small?: string;
};
story_spotlight?: boolean;
edhrec_rank?: number;
penny_rank?: number;
preview?: {
source?: string;
source_uri?: string;
previewed_at?: string;
};
prices?: {
usd?: string | null;
usd_foil?: string | null;
usd_etched?: string | null;
eur?: string | null;
eur_foil?: string | null;
tix: string | null;
};
related_uris?: {
gatherer: string;
tcgplayer_infinite_articles: string;
tcgplayer_infinite_decks: string;
edhrec: string;
};
purchase_uris?: {
tcgplayer: string;
cardmarket: string;
cardhoarder: string;
};
}
export interface ScryfallResponse {
data: CardData[];
has_more: boolean;
not_found?: string[];
object: "list";
total_cards: number;
}
export const getCardData = async (
cardName: string,
request = promiseWrappedRequest
): Promise<ScryfallResponse> => {
const query: string = querystring.stringify({ q: cardName });
const params: RequestOptions = {
url: `https://api.scryfall.com/cards/search?${query}`,
};
return request(params);
};
export const getMultipleCardData = async (
cardNames: string[],
request = promiseWrappedRequest
): Promise<ScryfallResponse> => {
if (cardNames.length === 0) {
// Return an empty response
return new Promise((resolve, reject) => {
resolve({
data: [],
has_more: false,
object: "list",
total_cards: 0,
} as ScryfallResponse);
});
}
const cardIdentifiers = cardNames.map((cardName) => ({
name: cardName,
}));
const postData = JSON.stringify({
identifiers: cardIdentifiers,
});
const params: RequestOptions = {
url: "https://api.scryfall.com/cards/collection",
method: "POST",
body: postData,
contentType: "application/json",
throw: false,
headers: {
accept: "application/json",
"user-agent": "obsidian-mtg",
},
};
return request(params);
};

24
src/settings.ts Normal file
View file

@ -0,0 +1,24 @@
export interface ObsidianPluginMtgSettings {
collection: {
// The file extension used for your collection CSV files
fileExtension: string;
// The name of the column where card names are stored
nameColumn: string;
// The name of the column in your csv where your counts are stored
countColumn: string;
// The number of milliseconds between collection count syncs
syncIntervalMs: number;
};
decklist: {
// Card Price Currency:
preferredCurrency: "usd" | "eur" | "tix";
// Show hyperlinks
showCardNamesAsHyperlinks: boolean;
// Show card previews
showCardPreviews: boolean;
// Show buylist
showBuylist: boolean;
// Show prices
hidePrices: boolean;
};
}

133
styles.css Normal file
View file

@ -0,0 +1,133 @@
/*
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/
.decklist {
padding: 8px;
}
ul.decklist__section-list {
list-style: none;
margin: 0;
padding: 0;
}
.decklist__section-totals {
display: flex;
}
.decklist__section-totals span {
flex: 1;
}
span.decklist__section-totals__count {
flex: 1;
}
.decklist__section-list-item {
font-weight: 400;
display: flex;
}
.decklist__section-list-item span {
flex: 1;
}
.count {
flex: 1;
}
span.card-name {
flex: 2;
}
.insufficient-count {
color: #666;
font-style: italic;
}
span.comment {
color: #999;
font-style: italic;
flex: 2;
}
.card-price {
}
.error {
color: #a33;
font-style: italic;
}
.decklist__section-heading {
font-weight: 800;
}
.footer {
}
.card-image {
height: 400px;
}
.buylist-container {
z-index: 0;
}
.buylist-line {
display: flex;
}
.buylist-line span {
flex: 1;
}
/* decklist__section-totals {
font-weight: 800;
} */
.decklist__section-totals .card-name {
flex: 4;
}
.buylist-line decklist__section-totals__count {
flex: 1;
}
.buylist-line .card-name {
flex: 4;
}
.buylist-line .decklist__section-totals {
flex: 1;
}
.card-image-container {
text-align: right;
position: absolute;
z-index: 1;
}
.buy-button {
flex: 1;
margin: 5px;
}
.buy-buttons-container {
flex: 2 2;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.markdown-source-view.mod-cm6
.cm-preview-code-block:has(> .block-language-mtg-deck) {
overflow: visible;
contain: initial !important;
}

15
test/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": "./",
"module": "commonjs",
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"isolatedModules": false,
"strict": false,
"noImplicitAny": false,
"typeRoots": ["../node_modules/@types"]
},
"exclude": ["../node_modules"],
"include": ["./**/*.ts"]
}

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": ["**/*.ts"]
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}