biblioplex 0.1.1 → 0.2.0

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/package.json +23 -27
  2. package/src/__tests__/args.test.js +39 -0
  3. package/src/__tests__/csv.test.js +33 -0
  4. package/src/__tests__/mcp.test.js +84 -0
  5. package/src/__tests__/mutate.test.js +91 -0
  6. package/src/__tests__/render.test.js +103 -0
  7. package/src/args.mjs +29 -6
  8. package/src/cli.mjs +38 -25
  9. package/src/commands/add.mjs +30 -51
  10. package/src/commands/deck.mjs +13 -47
  11. package/src/commands/edit.mjs +26 -38
  12. package/src/commands/export.mjs +57 -41
  13. package/src/commands/history.mjs +24 -0
  14. package/src/commands/import.mjs +52 -80
  15. package/src/commands/index.mjs +44 -20
  16. package/src/commands/login.mjs +29 -38
  17. package/src/commands/logout.mjs +13 -10
  18. package/src/commands/ls.mjs +13 -25
  19. package/src/commands/move.mjs +15 -35
  20. package/src/commands/prices.mjs +21 -0
  21. package/src/commands/recover.mjs +53 -0
  22. package/src/commands/resolve.mjs +37 -0
  23. package/src/commands/rm.mjs +15 -32
  24. package/src/commands/search.mjs +25 -35
  25. package/src/commands/summary.mjs +25 -28
  26. package/src/commands/undo.mjs +13 -28
  27. package/src/commands/value.mjs +30 -0
  28. package/src/commands/whoami.mjs +27 -19
  29. package/src/constants.mjs +34 -7
  30. package/src/csv.mjs +71 -0
  31. package/src/errors.mjs +13 -2
  32. package/src/mcp.mjs +227 -0
  33. package/src/mutate.mjs +142 -67
  34. package/src/oauth.mjs +200 -62
  35. package/src/output.mjs +27 -18
  36. package/src/render.mjs +135 -30
  37. package/src/store.mjs +39 -23
  38. package/README.md +0 -92
  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
@@ -1,665 +0,0 @@
1
- import { state } from './state.js';
2
-
3
- // ---- Normalizers ----
4
- export function normalizeFinish(raw) {
5
- if (!raw) return 'normal';
6
- const v = String(raw).toLowerCase().trim();
7
- if (!v || v === 'false' || v === 'no' || v === '0' || v === 'normal' || v === 'nonfoil' || v === 'non-foil') return 'normal';
8
- if (v === 'etched' || v === 'etched foil') return 'etched';
9
- if (v === 'true' || v === 'yes' || v === '1' || v === 'foil' || v.includes('foil')) return 'foil';
10
- return 'normal';
11
- }
12
-
13
- export function normalizeCondition(raw) {
14
- if (!raw) return 'near_mint';
15
- const v = String(raw).toLowerCase().trim().replace(/\s+/g, '_');
16
- if (v === 'mint' || v === 'm' || v === 'near_mint' || v === 'nm') return 'near_mint';
17
- if (v === 'lightly_played' || v === 'lp' || v === 'excellent' || v === 'ex' || v === 'light_played') return 'lightly_played';
18
- if (v === 'moderately_played' || v === 'mp' || v === 'played' || v === 'pl' || v === 'good') return 'moderately_played';
19
- if (v === 'heavily_played' || v === 'hp') return 'heavily_played';
20
- if (v === 'damaged' || v === 'dmg' || v === 'poor' || v === 'po') return 'damaged';
21
- return v;
22
- }
23
-
24
- export const LOCATION_TYPES = ['deck', 'container'];
25
- export const STORAGE_LOCATION_TYPES = ['container'];
26
- export const LEGACY_STORAGE_LOCATION_TYPES = ['binder', 'box'];
27
- export const DEFAULT_LOCATION_TYPE = 'container';
28
- export const CONTAINER_DISPLAY_MODES = ['visual', 'list'];
29
- export const DEFAULT_CONTAINER_DISPLAY_MODE = 'visual';
30
- export const DECK_BOARDS = ['main', 'sideboard', 'maybe'];
31
- export const DEFAULT_DECK_BOARD = 'main';
32
-
33
- export function normalizeContainerDisplayMode(raw, fallback = DEFAULT_CONTAINER_DISPLAY_MODE) {
34
- return CONTAINER_DISPLAY_MODES.includes(raw) ? raw : fallback;
35
- }
36
-
37
- function locationTypeFromRaw(raw) {
38
- const type = String(raw || '').trim().toLowerCase();
39
- if (type === 'deck') return 'deck';
40
- if (type === 'container' || LEGACY_STORAGE_LOCATION_TYPES.includes(type)) return 'container';
41
- return DEFAULT_LOCATION_TYPE;
42
- }
43
-
44
- function displayModeFromLegacyType(type, rawMode) {
45
- if (CONTAINER_DISPLAY_MODES.includes(rawMode)) return rawMode;
46
- if (type === 'box') return 'list';
47
- return DEFAULT_CONTAINER_DISPLAY_MODE;
48
- }
49
-
50
- // Parses a freeform string like "deck breya", "deck:breya", or "breya" into
51
- // a typed location. Legacy "binder:" and "box:" prefixes now normalize to
52
- // storage containers.
53
- export function parseLocationString(raw) {
54
- const s = String(raw || '').trim().toLowerCase().replace(/\s+/g, ' ');
55
- if (!s) return null;
56
- // Bare type word like "container" or "deck" — treat as that type with same name.
57
- if (LOCATION_TYPES.includes(s)) return { type: s, name: s };
58
- if (LEGACY_STORAGE_LOCATION_TYPES.includes(s)) return { type: 'container', name: s };
59
- const m = s.match(/^(deck|container|binder|box)[\s:]+(.+)$/);
60
- if (m) {
61
- const name = m[2].trim();
62
- return name ? { type: locationTypeFromRaw(m[1]), name } : null;
63
- }
64
- return { type: DEFAULT_LOCATION_TYPE, name: s };
65
- }
66
-
67
- // Accepts string (freeform), {type, name}, or null/undefined/''.
68
- // Returns null or a normalized {type, name}.
69
- export function normalizeLocation(raw) {
70
- if (raw == null || raw === '') return null;
71
- if (typeof raw === 'string') return parseLocationString(raw);
72
- if (typeof raw === 'object') {
73
- const name = String(raw.name || '').trim().toLowerCase().replace(/\s+/g, ' ');
74
- if (!name) return null;
75
- const type = locationTypeFromRaw(raw.type);
76
- return { type, name };
77
- }
78
- return null;
79
- }
80
-
81
- // Stable serialization for collectionKey + filter dedup. Returns "" for null.
82
- export function locationKey(loc) {
83
- const n = normalizeLocation(loc);
84
- return n ? n.type + ':' + n.name : '';
85
- }
86
-
87
- export function containerKey(container) {
88
- return locationKey(container);
89
- }
90
-
91
- export function defaultDeckMetadata(name = '') {
92
- return {
93
- title: name || '',
94
- description: '',
95
- format: '',
96
- commander: '',
97
- commanderScryfallId: '',
98
- commanderScryfallUri: '',
99
- commanderImageUrl: '',
100
- commanderBackImageUrl: '',
101
- partner: '',
102
- partnerScryfallId: '',
103
- partnerScryfallUri: '',
104
- partnerImageUrl: '',
105
- partnerBackImageUrl: '',
106
- coverName: '',
107
- coverScryfallId: '',
108
- coverImageUrl: '',
109
- coverBackImageUrl: '',
110
- coverFinish: '',
111
- companion: '',
112
- };
113
- }
114
-
115
- export function normalizeDeckBoard(raw) {
116
- const v = String(raw || '').trim().toLowerCase();
117
- return DECK_BOARDS.includes(v) ? v : DEFAULT_DECK_BOARD;
118
- }
119
-
120
- // A decklist entry is the logical "this is in the deck" record. Independent
121
- // from physical location: the same scryfallId can appear in a decklist while
122
- // the physical card sits in a different container (or isn't owned at all).
123
- export function normalizeDeckListEntry(raw) {
124
- if (!raw || typeof raw !== 'object') return null;
125
- const scryfallId = String(raw.scryfallId || '').trim();
126
- if (!scryfallId) return null;
127
- return {
128
- scryfallId,
129
- qty: Math.max(1, parseInt(raw.qty, 10) || 1),
130
- board: normalizeDeckBoard(raw.board),
131
- name: String(raw.name || '').trim(),
132
- setCode: String(raw.setCode || '').toLowerCase(),
133
- cn: String(raw.cn || '').trim(),
134
- imageUrl: String(raw.imageUrl || '').trim(),
135
- backImageUrl: String(raw.backImageUrl || '').trim(),
136
- rarity: String(raw.rarity || '').toLowerCase(),
137
- cmc: raw.cmc ?? null,
138
- typeLine: String(raw.typeLine || ''),
139
- colors: Array.isArray(raw.colors) ? [...raw.colors] : [],
140
- colorIdentity: Array.isArray(raw.colorIdentity) ? [...raw.colorIdentity] : [],
141
- };
142
- }
143
-
144
- export function normalizeDeckList(raw) {
145
- if (!Array.isArray(raw)) return [];
146
- return raw.map(normalizeDeckListEntry).filter(Boolean);
147
- }
148
-
149
- function normalizeBinderOrder(raw) {
150
- if (!Array.isArray(raw)) return [];
151
- return raw.map(value => {
152
- if (value == null || value === '') return null;
153
- return String(value);
154
- });
155
- }
156
-
157
- // Stable identity for a decklist entry. We keep separate entries for different
158
- // boards (so "Sol Ring main" and "Sol Ring sideboard" are independent), but
159
- // the same (scryfallId, board) tuple coalesces qty.
160
- export function deckListEntryKey(entry) {
161
- return entry.scryfallId + '|' + normalizeDeckBoard(entry.board);
162
- }
163
-
164
- export function addToDeckList(container, entry) {
165
- if (!container || container.type !== 'deck') return null;
166
- if (!Array.isArray(container.deckList)) container.deckList = [];
167
- const norm = normalizeDeckListEntry(entry);
168
- if (!norm) return null;
169
- const k = deckListEntryKey(norm);
170
- const existing = container.deckList.find(e => deckListEntryKey(e) === k);
171
- if (existing) {
172
- existing.qty += norm.qty;
173
- if (norm.imageUrl && !existing.imageUrl) existing.imageUrl = norm.imageUrl;
174
- if (norm.backImageUrl && !existing.backImageUrl) existing.backImageUrl = norm.backImageUrl;
175
- if (norm.name && !existing.name) existing.name = norm.name;
176
- if (norm.setCode && !existing.setCode) existing.setCode = norm.setCode;
177
- if (norm.cn && !existing.cn) existing.cn = norm.cn;
178
- return existing;
179
- }
180
- container.deckList.push(norm);
181
- return norm;
182
- }
183
-
184
- export function removeFromDeckList(container, scryfallId, board) {
185
- if (!container || !Array.isArray(container.deckList)) return false;
186
- const target = scryfallId + '|' + normalizeDeckBoard(board);
187
- const before = container.deckList.length;
188
- container.deckList = container.deckList.filter(e => deckListEntryKey(e) !== target);
189
- return container.deckList.length < before;
190
- }
191
-
192
- export function moveDeckListEntryBoard(container, scryfallId, fromBoard, toBoard) {
193
- if (!container || !Array.isArray(container.deckList)) return false;
194
- const fromKey = scryfallId + '|' + normalizeDeckBoard(fromBoard);
195
- const entry = container.deckList.find(e => deckListEntryKey(e) === fromKey);
196
- if (!entry) return false;
197
- const target = normalizeDeckBoard(toBoard);
198
- if (entry.board === target) return false;
199
- // If a different entry already exists on the target board, merge qty
200
- const merge = container.deckList.find(e =>
201
- e !== entry && e.scryfallId === scryfallId && e.board === target
202
- );
203
- if (merge) {
204
- merge.qty += entry.qty;
205
- container.deckList = container.deckList.filter(e => e !== entry);
206
- } else {
207
- entry.board = target;
208
- }
209
- return true;
210
- }
211
-
212
- // Resolve a decklist against the inventory. For each decklist entry, return
213
- // matching inventory entries (by scryfallId) so the deck view can render the
214
- // card image + show where it physically is.
215
- export function resolveDeckListEntry(entry, collection) {
216
- const matches = collection.filter(c => c && c.scryfallId === entry.scryfallId);
217
- // Prefer entries already physically in this deck (location.type === 'deck')
218
- // for the visual + finish display.
219
- const sorted = matches.slice().sort((a, b) => {
220
- const aDeck = normalizeLocation(a.location)?.type === 'deck' ? 0 : 1;
221
- const bDeck = normalizeLocation(b.location)?.type === 'deck' ? 0 : 1;
222
- return aDeck - bDeck;
223
- });
224
- const ownedQty = sorted.reduce((s, c) => s + (c.qty || 0), 0);
225
- return {
226
- entry,
227
- inventory: sorted,
228
- primary: sorted[0] || null,
229
- ownedQty,
230
- needed: Math.max(0, entry.qty - ownedQty),
231
- placeholder: ownedQty === 0,
232
- };
233
- }
234
-
235
- export function makeContainer(raw, now = Date.now()) {
236
- const loc = normalizeLocation(raw);
237
- if (!loc) return null;
238
- const rawType = String(raw?.type || loc.type || '').trim().toLowerCase();
239
- const out = {
240
- type: loc.type,
241
- name: loc.name,
242
- createdAt: now,
243
- updatedAt: now,
244
- };
245
- if (loc.type === 'deck') {
246
- out.deck = {
247
- ...defaultDeckMetadata(loc.name),
248
- ...(raw && typeof raw.deck === 'object' && !Array.isArray(raw.deck) ? raw.deck : {}),
249
- };
250
- out.deckList = normalizeDeckList(raw && raw.deckList);
251
- // Sharing state (auto-mirror): set when the user clicks "share". The ID
252
- // alone is the capability; the worker accepts PUT/DELETE from anyone
253
- // with it. Cleared on "stop sharing".
254
- if (typeof raw?.shareId === 'string' && raw.shareId) out.shareId = raw.shareId;
255
- if (raw?.shareIncludeTags) out.shareIncludeTags = true;
256
- out.deck.title = String(out.deck.title || loc.name);
257
- out.deck.description = String(out.deck.description || '');
258
- out.deck.format = String(out.deck.format || '');
259
- out.deck.commander = String(out.deck.commander || '');
260
- out.deck.commanderScryfallId = String(out.deck.commanderScryfallId || '');
261
- out.deck.commanderScryfallUri = String(out.deck.commanderScryfallUri || '');
262
- out.deck.commanderImageUrl = String(out.deck.commanderImageUrl || '');
263
- out.deck.commanderBackImageUrl = String(out.deck.commanderBackImageUrl || '');
264
- out.deck.partner = String(out.deck.partner || '');
265
- out.deck.partnerScryfallId = String(out.deck.partnerScryfallId || '');
266
- out.deck.partnerScryfallUri = String(out.deck.partnerScryfallUri || '');
267
- out.deck.partnerImageUrl = String(out.deck.partnerImageUrl || '');
268
- out.deck.partnerBackImageUrl = String(out.deck.partnerBackImageUrl || '');
269
- out.deck.coverName = String(out.deck.coverName || '');
270
- out.deck.coverScryfallId = String(out.deck.coverScryfallId || '');
271
- out.deck.coverImageUrl = String(out.deck.coverImageUrl || '');
272
- out.deck.coverBackImageUrl = String(out.deck.coverBackImageUrl || '');
273
- out.deck.coverFinish = String(out.deck.coverFinish || '');
274
- out.deck.companion = String(out.deck.companion || '');
275
- } else if (loc.type === 'container') {
276
- out.displayMode = displayModeFromLegacyType(rawType, raw?.displayMode);
277
- out.binderOrder = normalizeBinderOrder(raw && raw.binderOrder);
278
- }
279
- return out;
280
- }
281
-
282
- export function ensureContainer(raw, now = Date.now()) {
283
- const container = makeContainer(raw, now);
284
- if (!container) return null;
285
- const key = containerKey(container);
286
- const existing = state.containers && state.containers[key];
287
- if (existing) {
288
- existing.type = container.type;
289
- existing.name = container.name;
290
- if (container.type === 'deck') {
291
- existing.deck = {
292
- ...defaultDeckMetadata(container.name),
293
- ...(existing.deck && typeof existing.deck === 'object' ? existing.deck : {}),
294
- };
295
- if (!existing.deck.title) existing.deck.title = container.name;
296
- if (!Array.isArray(existing.deckList)) existing.deckList = [];
297
- // Preserve sharing state — only stamp from raw if not already set, so
298
- // calls without a shareId don't clobber an active share.
299
- if (container.shareId && !existing.shareId) existing.shareId = container.shareId;
300
- if (container.shareIncludeTags) existing.shareIncludeTags = true;
301
- } else if (container.type === 'container') {
302
- existing.displayMode = normalizeContainerDisplayMode(existing.displayMode, container.displayMode);
303
- if (!Array.isArray(existing.binderOrder)) existing.binderOrder = normalizeBinderOrder(container.binderOrder);
304
- }
305
- if (!existing.createdAt) existing.createdAt = container.createdAt;
306
- if (!existing.updatedAt) existing.updatedAt = container.updatedAt;
307
- return existing;
308
- }
309
- if (!state.containers || typeof state.containers !== 'object' || Array.isArray(state.containers)) {
310
- state.containers = {};
311
- }
312
- state.containers[key] = container;
313
- return container;
314
- }
315
-
316
- export function normalizeContainers(rawContainers = {}) {
317
- const out = {};
318
- if (!rawContainers || typeof rawContainers !== 'object' || Array.isArray(rawContainers)) return out;
319
- for (const raw of Object.values(rawContainers)) {
320
- const createdAt = raw?.createdAt || Date.now();
321
- const c = makeContainer(raw, raw?.updatedAt || createdAt);
322
- if (!c) continue;
323
- c.createdAt = createdAt;
324
- c.updatedAt = raw.updatedAt || c.updatedAt;
325
- if (c.type === 'deck' && raw.deck && typeof raw.deck === 'object') {
326
- c.deck = {
327
- ...defaultDeckMetadata(c.name),
328
- ...raw.deck,
329
- };
330
- c.deck.title = String(c.deck.title || c.name);
331
- c.deck.description = String(c.deck.description || '');
332
- c.deck.format = String(c.deck.format || '');
333
- c.deck.commander = String(c.deck.commander || '');
334
- c.deck.commanderScryfallId = String(c.deck.commanderScryfallId || '');
335
- c.deck.commanderScryfallUri = String(c.deck.commanderScryfallUri || '');
336
- c.deck.commanderImageUrl = String(c.deck.commanderImageUrl || '');
337
- c.deck.commanderBackImageUrl = String(c.deck.commanderBackImageUrl || '');
338
- c.deck.partner = String(c.deck.partner || '');
339
- c.deck.partnerScryfallId = String(c.deck.partnerScryfallId || '');
340
- c.deck.partnerScryfallUri = String(c.deck.partnerScryfallUri || '');
341
- c.deck.partnerImageUrl = String(c.deck.partnerImageUrl || '');
342
- c.deck.partnerBackImageUrl = String(c.deck.partnerBackImageUrl || '');
343
- c.deck.coverName = String(c.deck.coverName || '');
344
- c.deck.coverScryfallId = String(c.deck.coverScryfallId || '');
345
- c.deck.coverImageUrl = String(c.deck.coverImageUrl || '');
346
- c.deck.coverBackImageUrl = String(c.deck.coverBackImageUrl || '');
347
- c.deck.coverFinish = String(c.deck.coverFinish || '');
348
- c.deck.companion = String(c.deck.companion || '');
349
- c.deckList = normalizeDeckList(c.deckList);
350
- } else if (c.type === 'container') {
351
- c.displayMode = normalizeContainerDisplayMode(raw.displayMode, c.displayMode);
352
- c.binderOrder = normalizeBinderOrder(raw.binderOrder);
353
- }
354
- out[containerKey(c)] = c;
355
- }
356
- return out;
357
- }
358
-
359
- export function ensureContainersForCollection(collection = state.collection) {
360
- for (const c of collection || []) {
361
- const loc = normalizeLocation(c.location);
362
- if (loc) {
363
- ensureContainer(loc);
364
- if (loc.type === 'deck') c.deckBoard = normalizeDeckBoard(c.deckBoard);
365
- else if (Object.prototype.hasOwnProperty.call(c, 'deckBoard')) delete c.deckBoard;
366
- }
367
- }
368
- }
369
-
370
- // Display label "type:name" used in filter dropdowns + datalist suggestions.
371
- export function formatLocationLabel(loc) {
372
- const n = normalizeLocation(loc);
373
- return n ? n.type + ':' + n.name : '';
374
- }
375
-
376
- export function normalizeLanguage(raw) {
377
- return String(raw || 'en').trim().toLowerCase() || 'en';
378
- }
379
-
380
- export function normalizeTag(raw) {
381
- if (raw == null) return '';
382
- return String(raw).trim().toLowerCase().replace(/\s+/g, ' ');
383
- }
384
-
385
- export function normalizeTags(rawList) {
386
- if (!Array.isArray(rawList)) return [];
387
- const out = new Set();
388
- for (const raw of rawList) {
389
- const t = normalizeTag(raw);
390
- if (t) out.add(t);
391
- }
392
- return Array.from(out);
393
- }
394
-
395
- function normalizeNullableNumber(raw) {
396
- if (raw == null || raw === '') return null;
397
- const n = typeof raw === 'number' ? raw : parseFloat(raw);
398
- return Number.isFinite(n) ? n : null;
399
- }
400
-
401
- function normalizeStringArray(raw, fallback = null) {
402
- if (!Array.isArray(raw)) return fallback;
403
- return raw.map(v => String(v)).filter(Boolean);
404
- }
405
-
406
- function normalizeObject(raw, fallback = {}) {
407
- return raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : fallback;
408
- }
409
-
410
- // ---- Entry shape ----
411
- export function normalizeCollectionEntry(data = {}, { preserveResolvedFields = false } = {}) {
412
- const location = normalizeLocation(data.location);
413
- const entry = {
414
- name: String(data.name || ''),
415
- setCode: String(data.setCode || '').toLowerCase(),
416
- setName: String(data.setName || ''),
417
- cn: String(data.cn || ''),
418
- finish: normalizeFinish(data.finish),
419
- qty: Math.max(1, parseInt(data.qty, 10) || 1),
420
- condition: normalizeCondition(data.condition),
421
- language: normalizeLanguage(data.language),
422
- location,
423
- scryfallId: String(data.scryfallId || ''),
424
- rarity: String(data.rarity || '').toLowerCase(),
425
- price: normalizeNullableNumber(data.price),
426
- priceFallback: Boolean(data.priceFallback),
427
- tags: normalizeTags(data.tags),
428
- deckBoard: location?.type === 'deck' ? normalizeDeckBoard(data.deckBoard) : undefined,
429
- imageUrl: null,
430
- backImageUrl: null,
431
- cmc: null,
432
- colors: null,
433
- colorIdentity: [],
434
- typeLine: null,
435
- oracleText: '',
436
- legalities: {},
437
- finishes: [],
438
- resolvedName: null,
439
- scryfallUri: null,
440
- };
441
-
442
- if (preserveResolvedFields) {
443
- entry.imageUrl = data.imageUrl == null ? null : String(data.imageUrl);
444
- entry.backImageUrl = data.backImageUrl == null ? null : String(data.backImageUrl);
445
- entry.cmc = normalizeNullableNumber(data.cmc);
446
- entry.colors = normalizeStringArray(data.colors, data.colors == null ? null : []);
447
- entry.colorIdentity = normalizeStringArray(data.colorIdentity, []);
448
- entry.typeLine = data.typeLine == null ? null : String(data.typeLine);
449
- entry.oracleText = data.oracleText == null ? '' : String(data.oracleText);
450
- entry.legalities = normalizeObject(data.legalities);
451
- entry.finishes = normalizeStringArray(data.finishes, []);
452
- entry.resolvedName = data.resolvedName == null ? null : String(data.resolvedName);
453
- entry.scryfallUri = data.scryfallUri == null ? null : String(data.scryfallUri);
454
- if (data._source && typeof data._source === 'object' && !Array.isArray(data._source)) {
455
- entry._source = { ...data._source };
456
- }
457
- }
458
-
459
- if (location?.type !== 'deck') delete entry.deckBoard;
460
- return entry;
461
- }
462
-
463
- export function makeEntry(data) {
464
- return normalizeCollectionEntry(data);
465
- }
466
-
467
- // ---- Keying + coalescing ----
468
- export function collectionKey(c) {
469
- const locKey = locationKey(c.location);
470
- const boardPart = locKey.startsWith('deck:') ? ':' + normalizeDeckBoard(c.deckBoard) : '';
471
- return (c.scryfallId || (c.setCode + ':' + c.cn + ':' + c.name)) + ':' + c.finish + ':' + c.condition + ':' + c.language + ':' + locKey + boardPart;
472
- }
473
-
474
- export function coalesceCollection() {
475
- const byKey = new Map();
476
- for (const c of state.collection) {
477
- const k = collectionKey(c);
478
- if (byKey.has(k)) {
479
- const survivor = byKey.get(k);
480
- survivor.qty += c.qty;
481
- survivor.tags = normalizeTags([...(survivor.tags || []), ...(c.tags || [])]);
482
- } else {
483
- if (normalizeLocation(c.location)?.type === 'deck') c.deckBoard = normalizeDeckBoard(c.deckBoard);
484
- byKey.set(k, c);
485
- }
486
- }
487
- state.collection = Array.from(byKey.values());
488
- }
489
-
490
- export function allCollectionTags() {
491
- const set = new Set();
492
- for (const c of state.collection) {
493
- if (!Array.isArray(c.tags)) continue;
494
- for (const t of c.tags) {
495
- if (t) set.add(t);
496
- }
497
- }
498
- return Array.from(set).sort();
499
- }
500
-
501
- // Returns deduped sorted list of {type, name} objects across the collection.
502
- export function allCollectionLocations(collection = state.collection) {
503
- const byKey = new Map();
504
- for (const c of collection) {
505
- const loc = normalizeLocation(c.location);
506
- if (!loc) continue;
507
- const k = loc.type + ':' + loc.name;
508
- if (!byKey.has(k)) byKey.set(k, loc);
509
- }
510
- return Array.from(byKey.values()).sort((a, b) =>
511
- a.type.localeCompare(b.type) || a.name.localeCompare(b.name)
512
- );
513
- }
514
-
515
- export function allContainers() {
516
- ensureContainersForCollection();
517
- return Object.values(state.containers || {}).sort((a, b) =>
518
- a.type.localeCompare(b.type) || a.name.localeCompare(b.name)
519
- );
520
- }
521
-
522
- export function containerStats(container, collection = state.collection) {
523
- const key = containerKey(container);
524
- const cards = (collection || []).filter(c => locationKey(c.location) === key);
525
- return {
526
- unique: cards.length,
527
- total: cards.reduce((sum, c) => sum + (parseInt(c.qty, 10) || 0), 0),
528
- value: cards.reduce((sum, c) => sum + (c.price || 0) * (parseInt(c.qty, 10) || 0), 0),
529
- };
530
- }
531
-
532
- export function renameContainer(beforeRaw, afterRaw) {
533
- const before = normalizeLocation(beforeRaw);
534
- const after = normalizeLocation(afterRaw);
535
- if (!before || !after) return false;
536
- const beforeKey = locationKey(before);
537
- const afterKey = locationKey(after);
538
- if (beforeKey === afterKey) return true;
539
-
540
- const existing = state.containers?.[beforeKey];
541
- ensureContainer(after);
542
- if (existing && state.containers?.[afterKey]) {
543
- const target = state.containers[afterKey];
544
- target.createdAt = existing.createdAt || target.createdAt;
545
- target.updatedAt = Date.now();
546
- if (before.type === 'deck' && after.type === 'deck') {
547
- const previousDeck = existing.deck && typeof existing.deck === 'object' ? existing.deck : {};
548
- target.deck = {
549
- ...defaultDeckMetadata(after.name),
550
- ...previousDeck,
551
- title: !previousDeck.title || previousDeck.title === before.name ? after.name : previousDeck.title,
552
- };
553
- target.deckList = normalizeDeckList(existing.deckList);
554
- if (existing.shareId && !target.shareId) target.shareId = existing.shareId;
555
- if (existing.shareIncludeTags) target.shareIncludeTags = true;
556
- } else if (before.type === 'container' && after.type === 'container') {
557
- target.displayMode = normalizeContainerDisplayMode(existing.displayMode);
558
- target.binderOrder = normalizeBinderOrder(existing.binderOrder);
559
- }
560
- }
561
- if (state.containers) delete state.containers[beforeKey];
562
- for (const c of state.collection) {
563
- if (locationKey(c.location) === beforeKey) c.location = { ...after };
564
- }
565
- return true;
566
- }
567
-
568
- export function deleteEmptyContainer(raw) {
569
- const loc = normalizeLocation(raw);
570
- if (!loc) return false;
571
- const key = locationKey(loc);
572
- if (state.collection.some(c => locationKey(c.location) === key)) return false;
573
- if (state.containers) delete state.containers[key];
574
- return true;
575
- }
576
-
577
- // Delete a container and clear the location on every card that was in it.
578
- // Returns the number of cards whose location was cleared.
579
- export function deleteContainerAndUnlocateCards(raw) {
580
- const loc = normalizeLocation(raw);
581
- if (!loc) return 0;
582
- const key = locationKey(loc);
583
- let cleared = 0;
584
- for (const c of state.collection) {
585
- if (locationKey(c.location) === key) {
586
- c.location = null;
587
- cleared++;
588
- }
589
- }
590
- if (state.containers) delete state.containers[key];
591
- return cleared;
592
- }
593
-
594
- // Build a `loc:` search token from a typed location (or legacy string).
595
- export function quoteLocationForSearch(loc) {
596
- const label = typeof loc === 'string' ? loc : formatLocationLabel(loc);
597
- return /\s/.test(label) ? `"${label}"` : label;
598
- }
599
-
600
- // ---- Pricing ----
601
- export function getUsdPrice(card, finish) {
602
- const prices = card?.prices || {};
603
- const exact = finish === 'foil' ? prices.usd_foil
604
- : finish === 'etched' ? prices.usd_etched
605
- : prices.usd;
606
- const exactPrice = parseFloat(exact);
607
- if (exactPrice) return { price: exactPrice, fallback: false };
608
-
609
- const fallbackPrice = parseFloat(prices.usd);
610
- if (finish !== 'normal' && fallbackPrice) return { price: fallbackPrice, fallback: true };
611
-
612
- return { price: null, fallback: false };
613
- }
614
-
615
- // ---- Image URLs ----
616
- export function getCardImageUrl(card) {
617
- if (!card) return null;
618
- if (card.image_uris) return card.image_uris.normal || card.image_uris.small;
619
- if (card.card_faces?.length && card.card_faces[0].image_uris) {
620
- return card.card_faces[0].image_uris.normal || card.card_faces[0].image_uris.small;
621
- }
622
- return null;
623
- }
624
-
625
- export function getCardBackImageUrl(card) {
626
- if (!card) return null;
627
- const faces = card.card_faces;
628
- if (faces?.length >= 2 && faces[1].image_uris) {
629
- return faces[1].image_uris.normal || faces[1].image_uris.small;
630
- }
631
- return null;
632
- }
633
-
634
- export function applyScryfallCardResolution(entry, card, { priceMode = 'fill' } = {}) {
635
- if (!entry || !card) return entry;
636
- entry.scryfallId = card.id || entry.scryfallId || '';
637
- entry.resolvedName = card.name || entry.resolvedName || entry.name || '';
638
- entry.setCode = card.set || entry.setCode || '';
639
- entry.setName = card.set_name || entry.setName || '';
640
- entry.cn = card.collector_number || entry.cn || '';
641
- entry.rarity = String(entry.rarity || card.rarity || '').toLowerCase();
642
- entry.cmc = card.cmc ?? null;
643
- entry.colors = card.colors || (card.card_faces?.[0]?.colors) || [];
644
- entry.colorIdentity = card.color_identity || [];
645
- entry.typeLine = card.type_line || (card.card_faces?.map(f => f.type_line).filter(Boolean).join(' // ') || '');
646
- entry.oracleText = card.oracle_text || (card.card_faces?.map(f => f.oracle_text).filter(Boolean).join(' // ') || '');
647
- entry.legalities = card.legalities || {};
648
- entry.finishes = Array.isArray(card.finishes) ? [...card.finishes] : [];
649
- entry.scryfallUri = card.scryfall_uri || '';
650
- entry.imageUrl = getCardImageUrl(card);
651
- entry.backImageUrl = getCardBackImageUrl(card);
652
- if (priceMode === 'replace' || !entry.price) {
653
- const priced = getUsdPrice(card, entry.finish);
654
- entry.price = priced.price;
655
- entry.priceFallback = priced.fallback;
656
- } else {
657
- entry.priceFallback = Boolean(entry.priceFallback);
658
- }
659
- return entry;
660
- }
661
-
662
- export function biggerImageUrl(url) {
663
- if (!url) return url;
664
- return url.replace('/normal/', '/large/');
665
- }