biblioplex 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +44 -70
  2. package/package.json +17 -27
  3. package/src/__tests__/args.test.js +39 -0
  4. package/src/__tests__/csv.test.js +33 -0
  5. package/src/__tests__/mcp.test.js +84 -0
  6. package/src/__tests__/mutate.test.js +91 -0
  7. package/src/__tests__/render.test.js +103 -0
  8. package/src/args.mjs +29 -6
  9. package/src/cli.mjs +38 -25
  10. package/src/commands/add.mjs +30 -51
  11. package/src/commands/deck.mjs +13 -47
  12. package/src/commands/edit.mjs +26 -38
  13. package/src/commands/export.mjs +57 -41
  14. package/src/commands/history.mjs +24 -0
  15. package/src/commands/import.mjs +52 -80
  16. package/src/commands/index.mjs +44 -20
  17. package/src/commands/login.mjs +29 -38
  18. package/src/commands/logout.mjs +13 -10
  19. package/src/commands/ls.mjs +13 -25
  20. package/src/commands/move.mjs +15 -35
  21. package/src/commands/prices.mjs +21 -0
  22. package/src/commands/recover.mjs +53 -0
  23. package/src/commands/resolve.mjs +37 -0
  24. package/src/commands/rm.mjs +15 -32
  25. package/src/commands/search.mjs +25 -35
  26. package/src/commands/summary.mjs +25 -28
  27. package/src/commands/undo.mjs +13 -28
  28. package/src/commands/value.mjs +30 -0
  29. package/src/commands/whoami.mjs +27 -19
  30. package/src/constants.mjs +34 -7
  31. package/src/csv.mjs +71 -0
  32. package/src/errors.mjs +13 -2
  33. package/src/mcp.mjs +227 -0
  34. package/src/mutate.mjs +142 -67
  35. package/src/oauth.mjs +200 -62
  36. package/src/output.mjs +27 -18
  37. package/src/render.mjs +135 -30
  38. package/src/store.mjs +39 -23
  39. package/src/api.mjs +0 -110
  40. package/src/commands/container.mjs +0 -83
  41. package/src/commands/deckExport.mjs +0 -48
  42. package/src/commands/show.mjs +0 -33
  43. package/src/commands/tag.mjs +0 -40
  44. package/src/commands/writeHelpers.mjs +0 -82
  45. package/src/scryfall.mjs +0 -81
  46. package/src/snapshot.mjs +0 -77
  47. package/vendor/README.md +0 -20
  48. package/vendor/adapters.js +0 -443
  49. package/vendor/collection.js +0 -665
  50. package/vendor/deckExport.js +0 -219
  51. package/vendor/importMerge.js +0 -22
  52. package/vendor/importParsing.js +0 -119
  53. package/vendor/portableArchive.js +0 -151
  54. package/vendor/searchCore.js +0 -223
  55. package/vendor/state.js +0 -91
  56. package/vendor/storageSchema.js +0 -75
  57. package/vendor/syncOps.js +0 -188
package/src/scryfall.mjs DELETED
@@ -1,81 +0,0 @@
1
- // Direct Scryfall lookups for resolving printings on `add` and `import`.
2
- // Direct (not via the server's MCP tool) so bulk imports aren't throttled by the
3
- // worker's 60/60s MCP limit; we apply our own polite delay instead. Scryfall is
4
- // already the product's card-data source.
5
- import { VERSION } from './constants.mjs';
6
- import { getUsdPrice } from '../vendor/collection.js';
7
- import { CliError } from './errors.mjs';
8
-
9
- const SCRYFALL = 'https://api.scryfall.com';
10
- const USER_AGENT = `biblioplex-cli/${VERSION} (+https://biblioplex.bensonperry.com)`;
11
-
12
- export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
13
-
14
- async function sfGet(path, fetchImpl) {
15
- const res = await fetchImpl(SCRYFALL + path, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' } });
16
- if (res.status === 404) return null;
17
- const data = await res.json().catch(() => ({}));
18
- if (res.status === 429) throw new CliError('scryfall rate limit — slow down and retry', 4);
19
- if (!res.ok) throw new CliError('scryfall error: ' + (data.details || res.status));
20
- return data;
21
- }
22
-
23
- export function getById(id, fetchImpl = fetch) {
24
- return sfGet('/cards/' + encodeURIComponent(id), fetchImpl);
25
- }
26
-
27
- export function getBySetCn(set, cn, fetchImpl = fetch) {
28
- return sfGet(`/cards/${encodeURIComponent(String(set).toLowerCase())}/${encodeURIComponent(cn)}`, fetchImpl);
29
- }
30
-
31
- export function getByName(name, { set, fetchImpl = fetch } = {}) {
32
- const q = new URLSearchParams({ fuzzy: name });
33
- if (set) q.set('set', String(set).toLowerCase());
34
- return sfGet('/cards/named?' + q.toString(), fetchImpl);
35
- }
36
-
37
- // Resolve the most specific identifier available to a Scryfall card object.
38
- export async function resolvePrinting({ scryfallId, set, cn, name, fetchImpl = fetch }) {
39
- let card = null;
40
- if (scryfallId) card = await getById(scryfallId, fetchImpl);
41
- else if (set && cn) card = await getBySetCn(set, cn, fetchImpl);
42
- else if (name) card = await getByName(name, { set, fetchImpl });
43
- if (!card || card.object === 'error') return null;
44
- return card;
45
- }
46
-
47
- // Map a Scryfall card to the resolved-field shape normalizeCollectionEntry
48
- // expects (handles double-faced cards for colors/image/oracle).
49
- export function cardToFields(card, finish = 'normal') {
50
- const faces = Array.isArray(card.card_faces) ? card.card_faces : [];
51
- const front = faces[0] || {};
52
- const back = faces[1];
53
- const colors = card.colors ?? (faces.length ? [...new Set(faces.flatMap(f => f.colors || []))] : []);
54
- // Match the web app's resolution: join faces with " // " and use getUsdPrice
55
- // (which falls back to the non-foil price for foil/etched when the exact-finish
56
- // price is missing, setting priceFallback).
57
- const oracleText = card.oracle_text ?? (faces.length ? faces.map(f => f.oracle_text || '').join(' // ') : '');
58
- const typeLine = card.type_line || (faces.length ? faces.map(f => f.type_line || '').filter(Boolean).join(' // ') : '') || '';
59
- const { price, fallback } = getUsdPrice(card, finish);
60
- return {
61
- scryfallId: card.id,
62
- setCode: card.set,
63
- setName: card.set_name,
64
- cn: card.collector_number,
65
- name: card.name,
66
- rarity: card.rarity,
67
- cmc: card.cmc,
68
- colors,
69
- colorIdentity: card.color_identity || [],
70
- typeLine,
71
- oracleText,
72
- legalities: card.legalities || {},
73
- finishes: card.finishes || [],
74
- imageUrl: card.image_uris?.normal || front.image_uris?.normal || null,
75
- backImageUrl: back?.image_uris?.normal || null,
76
- resolvedName: card.name,
77
- scryfallUri: card.scryfall_uri || null,
78
- price,
79
- priceFallback: fallback,
80
- };
81
- }
package/src/snapshot.mjs DELETED
@@ -1,77 +0,0 @@
1
- // Read-side helpers over a bootstrap snapshot, reusing the app's search core.
2
- import { tokenizeSearch, matchSearch, passesMultiselectFilters, compareCards } from '../vendor/searchCore.js';
3
- import { locationKey } from '../vendor/collection.js';
4
-
5
- export function emptySnapshot() {
6
- return {
7
- app: { schemaVersion: 1, collection: [], containers: {}, ui: { selectedFormat: '' } },
8
- history: [],
9
- shares: [],
10
- };
11
- }
12
-
13
- export function collectionOf(snapshot) {
14
- return snapshot?.app?.collection || [];
15
- }
16
-
17
- export function containersOf(snapshot) {
18
- return snapshot?.app?.containers || {};
19
- }
20
-
21
- // Filter + sort a collection with the exact app grammar and sort order.
22
- export function runQuery(collection, query = '', { sort = 'name', dir = 'asc', filters = {} } = {}) {
23
- const tokens = tokenizeSearch(query || '');
24
- const filtered = (collection || []).filter(c => matchSearch(c, tokens) && passesMultiselectFilters(c, filters));
25
- const sign = dir === 'desc' ? -1 : 1;
26
- return [...filtered].sort((a, b) => sign * compareCards(a, b, sort));
27
- }
28
-
29
- // Parse a container reference like "deck:breya", "breya", or "binder:rares".
30
- // Returns { type, name } where type may be null if unqualified.
31
- export function parseContainerRef(ref) {
32
- const m = String(ref || '').trim().match(/^(deck|container|binder|box)\s*[:]\s*(.+)$/i);
33
- if (m) {
34
- const t = m[1].toLowerCase();
35
- return { type: t === 'deck' ? 'deck' : 'container', name: m[2].trim().toLowerCase() };
36
- }
37
- return { type: null, name: String(ref || '').trim().toLowerCase() };
38
- }
39
-
40
- export function listContainers(snapshot, type = null) {
41
- const containers = Object.values(containersOf(snapshot));
42
- const collection = collectionOf(snapshot);
43
- return containers
44
- .filter(c => !type || c.type === type)
45
- .map(c => {
46
- const key = c.type + ':' + c.name;
47
- const cards = collection.filter(card => locationKey(card.location) === key);
48
- const stats = summarize(cards);
49
- return { type: c.type, name: c.name, unique: stats.unique, total: stats.total, value: stats.value, meta: c.deck || null };
50
- })
51
- .sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
52
- }
53
-
54
- export function findContainer(snapshot, ref) {
55
- const { type, name } = parseContainerRef(ref);
56
- const containers = Object.values(containersOf(snapshot));
57
- const matches = containers.filter(c => c.name === name && (!type || c.type === type));
58
- return { matches, type, name };
59
- }
60
-
61
- export function containerCards(snapshot, container) {
62
- const key = container.type + ':' + container.name;
63
- return collectionOf(snapshot).filter(card => locationKey(card.location) === key);
64
- }
65
-
66
- export function summarize(collection) {
67
- let unique = 0;
68
- let total = 0;
69
- let value = 0;
70
- for (const c of collection || []) {
71
- unique += 1;
72
- const qty = parseInt(c.qty, 10) || 0;
73
- total += qty;
74
- if (typeof c.price === 'number') value += c.price * qty;
75
- }
76
- return { unique, total, value: Math.round(value * 100) / 100 };
77
- }
package/vendor/README.md DELETED
@@ -1,20 +0,0 @@
1
- # vendor/
2
-
3
- Generated by `scripts/sync-vendor.mjs` — do not edit by hand.
4
-
5
- These files are byte-identical copies of the biblioplex web app modules the
6
- CLI reuses (search grammar, CSV adapters, collection model, sync op diffing,
7
- deck export). The web app is the source of truth; re-run `npm run sync-vendor`
8
- after changing any of them.
9
-
10
- Vendored modules:
11
- - adapters.js
12
- - collection.js
13
- - deckExport.js
14
- - importMerge.js
15
- - importParsing.js
16
- - portableArchive.js
17
- - searchCore.js
18
- - state.js
19
- - storageSchema.js
20
- - syncOps.js
@@ -1,443 +0,0 @@
1
- // Format adapters for CSV import/export. Each adapter:
2
- // - detect(headers): returns true if the headers look like its format
3
- // - parse(rows): returns array of { entry, source } objects
4
- // - export(entries): returns full CSV text
5
- //
6
- // Source metadata is stashed on each entry as `entry._source[adapterId] = row`
7
- // so that exports back to the same format can preserve fields the canonical
8
- // model doesn't capture (Tradelist Count, Last Modified, Misprint, etc.).
9
- //
10
- // Adapter precedence (used by detectAdapter): moxfield > deckbox > manabox > canonical.
11
- // More-specific adapters detect first; canonical is the catch-all.
12
-
13
- import {
14
- makeEntry,
15
- normalizeFinish,
16
- normalizeCondition,
17
- normalizeLanguage,
18
- formatLocationLabel,
19
- } from './collection.js';
20
- import { parseTagsCell, serializeTagsCell } from './importParsing.js';
21
-
22
- // ---- shared helpers ----
23
- const lower = arr => arr.map(s => String(s || '').toLowerCase().trim());
24
- const csvCell = v => {
25
- const s = v == null ? '' : String(v);
26
- return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
27
- };
28
- const csvRow = arr => arr.map(csvCell).join(',');
29
-
30
- function rowToObject(headerRow, dataRow) {
31
- const out = {};
32
- for (let i = 0; i < headerRow.length; i++) {
33
- out[headerRow[i]] = dataRow[i] == null ? '' : String(dataRow[i]);
34
- }
35
- return out;
36
- }
37
-
38
- function attachSource(entry, adapterId, headerRow, dataRow) {
39
- if (!entry._source || typeof entry._source !== 'object') entry._source = {};
40
- entry._source[adapterId] = rowToObject(headerRow, dataRow);
41
- }
42
-
43
- function getSource(entry, adapterId) {
44
- return entry?._source?.[adapterId] || null;
45
- }
46
-
47
- // ---- Canonical adapter ----
48
- // The format the app already produces via exportCsv. Header aliases in
49
- // importParsing.js handles minor variations (manabox/deckbox/moxfield-like CSVs that
50
- // don't trip the more specific adapters' detect()).
51
- const CANONICAL_HEADER = [
52
- 'Name', 'Set code', 'Set name', 'Collector number', 'Foil', 'Rarity', 'Quantity',
53
- 'Scryfall ID', 'Condition', 'Language', 'Location',
54
- 'Purchase price', 'Purchase price currency', 'Purchase price note', 'Tags',
55
- ];
56
-
57
- const ALIASES = {
58
- name: ['name', 'card name', 'card'],
59
- setCode: ['set code', 'set', 'edition', 'setcode', 'set_code'],
60
- setName: ['set name', 'setname', 'edition name'],
61
- cn: ['collector number', 'card number', 'cn', 'collector_number', 'number'],
62
- finish: ['foil', 'finish', 'printing'],
63
- qty: ['quantity', 'count', 'qty'],
64
- condition: ['condition'],
65
- language: ['language', 'lang'],
66
- location: ['location', 'place', 'storage', 'where'],
67
- scryfallId: ['scryfall id', 'scryfall_id', 'scryfallid'],
68
- rarity: ['rarity'],
69
- price: ['purchase price', 'price', 'tcg market price'],
70
- tags: ['tags'],
71
- };
72
-
73
- function mapHeaders(headerRow) {
74
- const idx = {};
75
- const lc = lower(headerRow);
76
- for (const [key, aliases] of Object.entries(ALIASES)) {
77
- for (const a of aliases) {
78
- const i = lc.indexOf(a);
79
- if (i !== -1) { idx[key] = i; break; }
80
- }
81
- }
82
- return idx;
83
- }
84
-
85
- export const canonicalAdapter = {
86
- id: 'canonical',
87
- label: 'canonical CSV',
88
- // Canonical is the catch-all — it accepts any CSV that has a name/id column
89
- // plus enough to identify a printing.
90
- detect(headerRow) {
91
- const idx = mapHeaders(headerRow);
92
- const hasNameOrId = idx.name !== undefined || idx.scryfallId !== undefined;
93
- const hasSetAndCn = idx.setCode !== undefined && idx.cn !== undefined;
94
- return hasNameOrId || hasSetAndCn;
95
- },
96
- parse(rows) {
97
- if (!rows.length) return [];
98
- const headerRow = rows[0];
99
- const idx = mapHeaders(headerRow);
100
- const out = [];
101
- for (let r = 1; r < rows.length; r++) {
102
- const row = rows[r];
103
- const get = k => idx[k] !== undefined ? (row[idx[k]] || '').trim() : '';
104
- const entry = makeEntry({
105
- name: get('name'),
106
- setCode: get('setCode').toLowerCase(),
107
- setName: get('setName'),
108
- cn: get('cn'),
109
- finish: normalizeFinish(get('finish')),
110
- qty: parseInt(get('qty'), 10) || 1,
111
- condition: normalizeCondition(get('condition')),
112
- language: normalizeLanguage(get('language')),
113
- location: get('location'),
114
- scryfallId: get('scryfallId'),
115
- rarity: get('rarity').toLowerCase(),
116
- price: parseFloat(get('price')) || null,
117
- tags: parseTagsCell(get('tags')),
118
- });
119
- if (!entry.name && !entry.scryfallId && !(entry.setCode && entry.cn)) continue;
120
- attachSource(entry, 'canonical', headerRow, row);
121
- out.push(entry);
122
- }
123
- return out;
124
- },
125
- export(entries) {
126
- const rows = entries.map(c => csvRow([
127
- c.resolvedName || c.name,
128
- c.setCode,
129
- c.setName,
130
- c.cn,
131
- c.finish,
132
- c.rarity,
133
- c.qty,
134
- c.scryfallId,
135
- c.condition,
136
- c.language,
137
- formatLocationLabel(c.location),
138
- c.price ?? '',
139
- c.price ? 'USD' : '',
140
- c.priceFallback ? 'regular usd fallback; exact finish price unavailable' : '',
141
- serializeTagsCell(c.tags),
142
- ]));
143
- return csvRow(CANONICAL_HEADER) + '\n' + rows.join('\n');
144
- },
145
- };
146
-
147
- // ---- Moxfield adapter ----
148
- // Reference headers: Count, Tradelist Count, Name, Edition, Condition,
149
- // Language, Foil, Tags, Last Modified, Collector Number, Alter, Proxy,
150
- // Purchase Price.
151
- const MOXFIELD_HEADER = [
152
- 'Count', 'Tradelist Count', 'Name', 'Edition', 'Condition', 'Language',
153
- 'Foil', 'Tags', 'Last Modified', 'Collector Number', 'Alter', 'Proxy',
154
- 'Purchase Price',
155
- ];
156
- const MOXFIELD_REQUIRED = ['count', 'name', 'edition', 'collector number'];
157
- const MOXFIELD_HALLMARKS = ['tradelist count', 'last modified', 'alter', 'proxy'];
158
-
159
- function moxfieldFinishOf(raw) {
160
- const v = String(raw || '').toLowerCase().trim();
161
- if (v === 'foil') return 'foil';
162
- if (v === 'etched') return 'etched';
163
- return 'normal';
164
- }
165
-
166
- function moxfieldConditionOf(raw) {
167
- const v = String(raw || '').toLowerCase().trim();
168
- // Moxfield uses NM/LP/MP/HP/DMG abbreviations.
169
- return normalizeCondition(v);
170
- }
171
-
172
- export const moxfieldAdapter = {
173
- id: 'moxfield',
174
- label: 'Moxfield',
175
- detect(headerRow) {
176
- const lc = lower(headerRow);
177
- const hasRequired = MOXFIELD_REQUIRED.every(h => lc.includes(h));
178
- if (!hasRequired) return false;
179
- // At least one of the moxfield-only columns must be present so we don't
180
- // false-positive on a generic CSV that happens to have those four headers.
181
- return MOXFIELD_HALLMARKS.some(h => lc.includes(h));
182
- },
183
- parse(rows) {
184
- if (!rows.length) return [];
185
- const headerRow = rows[0];
186
- const lc = lower(headerRow);
187
- const i = name => lc.indexOf(name);
188
- const colCount = i('count');
189
- const colName = i('name');
190
- const colEdition = i('edition');
191
- const colCondition = i('condition');
192
- const colLanguage = i('language');
193
- const colFoil = i('foil');
194
- const colTags = i('tags');
195
- const colCn = i('collector number');
196
- const colPrice = i('purchase price');
197
- const out = [];
198
- for (let r = 1; r < rows.length; r++) {
199
- const row = rows[r];
200
- const get = idx => idx >= 0 ? (row[idx] || '').trim() : '';
201
- const name = get(colName);
202
- if (!name) continue;
203
- const entry = makeEntry({
204
- name,
205
- setCode: get(colEdition).toLowerCase(),
206
- cn: get(colCn),
207
- finish: moxfieldFinishOf(get(colFoil)),
208
- qty: parseInt(get(colCount), 10) || 1,
209
- condition: moxfieldConditionOf(get(colCondition)),
210
- language: normalizeLanguage(get(colLanguage)),
211
- price: parseFloat(get(colPrice)) || null,
212
- tags: get(colTags) ? get(colTags).split(',').map(t => t.trim()).filter(Boolean) : [],
213
- });
214
- attachSource(entry, 'moxfield', headerRow, row);
215
- out.push(entry);
216
- }
217
- return out;
218
- },
219
- export(entries) {
220
- const rows = entries.map(c => {
221
- const src = getSource(c, 'moxfield') || {};
222
- const finishOut = c.finish === 'foil' ? 'foil' : c.finish === 'etched' ? 'etched' : '';
223
- const condMap = { near_mint: 'NM', lightly_played: 'LP', moderately_played: 'MP', heavily_played: 'HP', damaged: 'DMG' };
224
- return csvRow([
225
- c.qty,
226
- src['Tradelist Count'] ?? 0,
227
- c.resolvedName || c.name,
228
- (c.setCode || '').toLowerCase(),
229
- condMap[c.condition] || 'NM',
230
- (c.language || 'en').toLowerCase(),
231
- finishOut,
232
- (Array.isArray(c.tags) ? c.tags.join(',') : ''),
233
- src['Last Modified'] ?? '',
234
- c.cn || '',
235
- src['Alter'] ?? 'False',
236
- src['Proxy'] ?? 'False',
237
- c.price ?? '',
238
- ]);
239
- });
240
- return csvRow(MOXFIELD_HEADER) + '\n' + rows.join('\n');
241
- },
242
- };
243
-
244
- // ---- ManaBox adapter ----
245
- // Reference headers: Name, Set code, Set name, Collector number, Foil, Rarity,
246
- // Quantity, ManaBox ID, Scryfall ID, Purchase price, Misprint, Altered,
247
- // Condition, Language, Purchase price currency.
248
- const MANABOX_HEADER = [
249
- 'Name', 'Set code', 'Set name', 'Collector number', 'Foil', 'Rarity',
250
- 'Quantity', 'ManaBox ID', 'Scryfall ID', 'Purchase price', 'Misprint',
251
- 'Altered', 'Condition', 'Language', 'Purchase price currency',
252
- ];
253
- const MANABOX_HALLMARKS = ['manabox id', 'misprint', 'altered', 'purchase price currency'];
254
-
255
- export const manaboxAdapter = {
256
- id: 'manabox',
257
- label: 'ManaBox',
258
- detect(headerRow) {
259
- const lc = lower(headerRow);
260
- // Need at least two manabox-specific columns to claim a manabox CSV.
261
- return MANABOX_HALLMARKS.filter(h => lc.includes(h)).length >= 2;
262
- },
263
- parse(rows) {
264
- if (!rows.length) return [];
265
- const headerRow = rows[0];
266
- const lc = lower(headerRow);
267
- const i = name => lc.indexOf(name);
268
- const out = [];
269
- for (let r = 1; r < rows.length; r++) {
270
- const row = rows[r];
271
- const get = idx => idx >= 0 ? (row[idx] || '').trim() : '';
272
- const name = get(i('name'));
273
- if (!name && !get(i('scryfall id'))) continue;
274
- const entry = makeEntry({
275
- name,
276
- setCode: get(i('set code')).toLowerCase(),
277
- setName: get(i('set name')),
278
- cn: get(i('collector number')),
279
- finish: normalizeFinish(get(i('foil'))),
280
- qty: parseInt(get(i('quantity')), 10) || 1,
281
- condition: normalizeCondition(get(i('condition'))),
282
- language: normalizeLanguage(get(i('language'))),
283
- scryfallId: get(i('scryfall id')),
284
- rarity: get(i('rarity')).toLowerCase(),
285
- price: parseFloat(get(i('purchase price'))) || null,
286
- });
287
- attachSource(entry, 'manabox', headerRow, row);
288
- out.push(entry);
289
- }
290
- return out;
291
- },
292
- export(entries) {
293
- const finishOut = f => f === 'foil' ? 'foil' : f === 'etched' ? 'etched' : 'normal';
294
- const condOut = {
295
- near_mint: 'near_mint', lightly_played: 'lightly_played',
296
- moderately_played: 'moderately_played', heavily_played: 'heavily_played',
297
- damaged: 'damaged',
298
- };
299
- const rows = entries.map(c => {
300
- const src = getSource(c, 'manabox') || {};
301
- return csvRow([
302
- c.resolvedName || c.name,
303
- (c.setCode || '').toLowerCase(),
304
- c.setName || src['Set name'] || '',
305
- c.cn || '',
306
- finishOut(c.finish),
307
- c.rarity || '',
308
- c.qty,
309
- src['ManaBox ID'] ?? '',
310
- c.scryfallId || '',
311
- c.price ?? '',
312
- src['Misprint'] ?? 'false',
313
- src['Altered'] ?? 'false',
314
- condOut[c.condition] || 'near_mint',
315
- c.language || 'en',
316
- c.price ? 'USD' : (src['Purchase price currency'] || ''),
317
- ]);
318
- });
319
- return csvRow(MANABOX_HEADER) + '\n' + rows.join('\n');
320
- },
321
- };
322
-
323
- // ---- Deckbox adapter ----
324
- // Reference headers: Count, Tradelist Count, Name, Edition, Card Number,
325
- // Condition, Language, Foil, Signed, Artist Proof, Altered Art, Misprint,
326
- // Promo, Textless, My Price.
327
- const DECKBOX_HEADER = [
328
- 'Count', 'Tradelist Count', 'Name', 'Edition', 'Card Number', 'Condition',
329
- 'Language', 'Foil', 'Signed', 'Artist Proof', 'Altered Art', 'Misprint',
330
- 'Promo', 'Textless', 'My Price',
331
- ];
332
- const DECKBOX_REQUIRED = ['count', 'name', 'edition', 'card number'];
333
- // "Card Number" + "Tradelist Count" + "Signed/Artist Proof/Altered Art" hallmarks.
334
- const DECKBOX_HALLMARKS = ['signed', 'artist proof', 'altered art', 'textless', 'my price'];
335
-
336
- function deckboxConditionOf(raw) {
337
- const v = String(raw || '').toLowerCase().trim();
338
- // Deckbox uses long names: "Near Mint", "Mint", "Good (Lightly Played)", etc.
339
- if (v.includes('near mint') || v === 'mint') return 'near_mint';
340
- if (v.includes('lightly') || v.includes('good')) return 'lightly_played';
341
- if (v.includes('played') || v.includes('moderate')) return 'moderately_played';
342
- if (v.includes('heavily') || v.includes('poor')) return 'heavily_played';
343
- if (v.includes('damaged')) return 'damaged';
344
- return normalizeCondition(v);
345
- }
346
-
347
- export const deckboxAdapter = {
348
- id: 'deckbox',
349
- label: 'Deckbox',
350
- detect(headerRow) {
351
- const lc = lower(headerRow);
352
- const hasRequired = DECKBOX_REQUIRED.every(h => lc.includes(h));
353
- if (!hasRequired) return false;
354
- return DECKBOX_HALLMARKS.some(h => lc.includes(h));
355
- },
356
- parse(rows) {
357
- if (!rows.length) return [];
358
- const headerRow = rows[0];
359
- const lc = lower(headerRow);
360
- const i = name => lc.indexOf(name);
361
- const out = [];
362
- for (let r = 1; r < rows.length; r++) {
363
- const row = rows[r];
364
- const get = idx => idx >= 0 ? (row[idx] || '').trim() : '';
365
- const name = get(i('name'));
366
- if (!name) continue;
367
- const foilRaw = get(i('foil'));
368
- const foilLc = foilRaw.toLowerCase();
369
- const finish = foilLc === 'foil' || foilLc === 'true' || foilLc === 'yes' ? 'foil' : 'normal';
370
- const entry = makeEntry({
371
- name,
372
- // Deckbox 'Edition' is the SET NAME, not the code. Stash and let
373
- // scryfall resolve via name+number.
374
- setName: get(i('edition')),
375
- cn: get(i('card number')),
376
- finish,
377
- qty: parseInt(get(i('count')), 10) || 1,
378
- condition: deckboxConditionOf(get(i('condition'))),
379
- language: normalizeLanguage(get(i('language'))),
380
- price: parseFloat(get(i('my price'))) || null,
381
- });
382
- attachSource(entry, 'deckbox', headerRow, row);
383
- out.push(entry);
384
- }
385
- return out;
386
- },
387
- export(entries) {
388
- const condMap = {
389
- near_mint: 'Near Mint', lightly_played: 'Good (Lightly Played)',
390
- moderately_played: 'Played', heavily_played: 'Heavily Played',
391
- damaged: 'Poor',
392
- };
393
- const rows = entries.map(c => {
394
- const src = getSource(c, 'deckbox') || {};
395
- return csvRow([
396
- c.qty,
397
- src['Tradelist Count'] ?? 0,
398
- c.resolvedName || c.name,
399
- c.setName || src['Edition'] || (c.setCode || '').toUpperCase(),
400
- c.cn || '',
401
- condMap[c.condition] || 'Near Mint',
402
- c.language || 'English',
403
- c.finish === 'foil' ? 'foil' : '',
404
- src['Signed'] ?? '',
405
- src['Artist Proof'] ?? '',
406
- src['Altered Art'] ?? '',
407
- src['Misprint'] ?? '',
408
- src['Promo'] ?? '',
409
- src['Textless'] ?? '',
410
- c.price ?? '',
411
- ]);
412
- });
413
- return csvRow(DECKBOX_HEADER) + '\n' + rows.join('\n');
414
- },
415
- };
416
-
417
- // ---- Registry + dispatch ----
418
- // Order matters — most-specific first. detectAdapter walks in order and
419
- // returns the first match. Canonical is the fallback catch-all.
420
- export const ADAPTERS = [moxfieldAdapter, deckboxAdapter, manaboxAdapter, canonicalAdapter];
421
-
422
- export function getAdapter(id) {
423
- return ADAPTERS.find(a => a.id === id) || null;
424
- }
425
-
426
- export function detectAdapter(headerRow) {
427
- for (const a of ADAPTERS) {
428
- if (a.detect(headerRow)) return a;
429
- }
430
- return null;
431
- }
432
-
433
- // Merge source metadata across two entries (used by mergeIntoCollection so
434
- // re-imports don't drop earlier-import preserved fields). The newer source
435
- // wins per-format.
436
- export function mergeSource(existing, incoming) {
437
- if (!incoming?._source) return existing;
438
- if (!existing._source || typeof existing._source !== 'object') existing._source = {};
439
- for (const [id, row] of Object.entries(incoming._source)) {
440
- existing._source[id] = row;
441
- }
442
- return existing;
443
- }