mirror of
https://github.com/sboulema/mtg-deck.git
synced 2026-07-22 06:50:39 +00:00
Initial commit
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
commit
c9914e55ea
38 changed files with 11967 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal 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
2
.eslintignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
npm node_modules
|
||||
build
|
||||
21
.eslintrc
Normal file
21
.eslintrc
Normal 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
71
.github/workflows/workflow.yml
vendored
Normal 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
22
.gitignore
vendored
Normal 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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
8
.prettierignore
Normal file
8
.prettierignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
main.js
|
||||
node_modules/
|
||||
.husky
|
||||
.circleci
|
||||
jest/
|
||||
jest.config.js
|
||||
*.md
|
||||
esbuild.config.mjs
|
||||
1
.prettierrc.json
Normal file
1
.prettierrc.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
68
README.md
Normal 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:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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
BIN
art/example_decklist.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 540 KiB |
BIN
art/example_settings.png
Normal file
BIN
art/example_settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
42
esbuild.config.mjs
Normal file
42
esbuild.config.mjs
Normal 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
8
jest.config.js
Normal 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
63
jest/fixtures/content.ts
Normal file
File diff suppressed because one or more lines are too long
2964
jest/fixtures/scryfall-data.ts
Normal file
2964
jest/fixtures/scryfall-data.ts
Normal file
File diff suppressed because it is too large
Load diff
22
jest/globalSetup.ts
Normal file
22
jest/globalSetup.ts
Normal 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
232
main.ts
Normal 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
10
manifest.json
Normal 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
6756
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
42
package.json
Normal file
42
package.json
Normal 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
13
register.js
Normal 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
78
src/collection.spec.ts
Normal 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
104
src/collection.ts
Normal 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
27
src/csv.spec.ts
Normal 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
94
src/csv.ts
Normal 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
65
src/dom-utils.ts
Normal 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
26
src/http.ts
Normal 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
72
src/renderer.spec.ts
Normal 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
683
src/renderer.ts
Normal 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
44
src/scryfall.spec.ts
Normal 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
189
src/scryfall.ts
Normal 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
24
src/settings.ts
Normal 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
133
styles.css
Normal 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
15
test/tsconfig.json
Normal 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
19
tsconfig.json
Normal 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
14
version-bump.mjs
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue