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
@@ -1,219 +0,0 @@
1
- const PRESETS = new Set(['plain', 'moxfield', 'arena', 'mtgo', 'csv', 'json']);
2
- const ALL_BOARDS = ['main', 'sideboard', 'maybe'];
3
-
4
- function clean(value) {
5
- return value == null ? '' : String(value).trim();
6
- }
7
-
8
- function quantity(card) {
9
- const qty = Number.parseInt(card?.qty, 10);
10
- return Number.isFinite(qty) && qty > 0 ? qty : 1;
11
- }
12
-
13
- function cardName(card) {
14
- return clean(card?.resolvedName || card?.name);
15
- }
16
-
17
- function deckBoard(card) {
18
- const board = clean(card?.deckBoard).toLowerCase();
19
- return board === 'sideboard' || board === 'maybe' ? board : 'main';
20
- }
21
-
22
- function setCode(card) {
23
- return clean(card?.setCode || card?.set).toUpperCase();
24
- }
25
-
26
- function collectorNumber(card) {
27
- return clean(card?.cn || card?.collectorNumber || card?.collector_number);
28
- }
29
-
30
- function finishMarker(card) {
31
- const finish = clean(card?.finish).toLowerCase();
32
- if (finish === 'foil') return '*F*';
33
- if (finish === 'etched' || finish === 'etched foil') return '*E*';
34
- return '';
35
- }
36
-
37
- function titleSlug(deckMeta) {
38
- const title = clean(deckMeta?.title || deckMeta?.name || 'deck');
39
- return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'deck';
40
- }
41
-
42
- function normalizePreset(preset) {
43
- const value = clean(preset || 'plain').toLowerCase();
44
- return PRESETS.has(value) ? value : 'plain';
45
- }
46
-
47
- function selectedBoardsForPreset(preset, options) {
48
- if (Array.isArray(options.boards)) {
49
- const boards = options.boards.map(b => clean(b).toLowerCase()).filter(b => ALL_BOARDS.includes(b));
50
- if (boards.length) return boards;
51
- }
52
- if (preset === 'moxfield' || preset === 'csv' || preset === 'json') return [...ALL_BOARDS];
53
- return ['main', 'sideboard'];
54
- }
55
-
56
- function cloneEntry(card, qty = quantity(card)) {
57
- return { card, qty, name: cardName(card), board: deckBoard(card) };
58
- }
59
-
60
- function collapseByName(entries) {
61
- const byName = new Map();
62
- for (const entry of entries) {
63
- const key = entry.name.toLowerCase();
64
- const existing = byName.get(key);
65
- if (existing) existing.qty += entry.qty;
66
- else byName.set(key, { ...entry });
67
- }
68
- return [...byName.values()];
69
- }
70
-
71
- function lineNameCard(name) {
72
- return { name, resolvedName: name, qty: 1 };
73
- }
74
-
75
- function extractCommanderSlot(slotName, rawName, mainEntries, warnings) {
76
- const name = clean(rawName);
77
- if (!name) return null;
78
- const match = mainEntries.find(entry => entry.qty > 0 && entry.name === name);
79
- if (!match) {
80
- warnings.push(`${slotName} "${name}" was not found in the mainboard; exported as name-only.`);
81
- return { card: lineNameCard(name), qty: 1, name, board: 'commander', nameOnly: true, slot: slotName };
82
- }
83
- match.qty -= 1;
84
- return { card: match.card, qty: 1, name: match.name, board: 'commander', slot: slotName };
85
- }
86
-
87
- function entriesForText(entries, preset) {
88
- return preset === 'moxfield' ? entries : collapseByName(entries);
89
- }
90
-
91
- function sectionLines(label, entries, options = {}) {
92
- if (!entries.length) return [];
93
- return [label, ...entries.map(entry => formatDeckTextLine({ ...entry.card, qty: entry.qty }, options))];
94
- }
95
-
96
- function csvCell(value) {
97
- const text = value == null ? '' : String(value);
98
- return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
99
- }
100
-
101
- function csvRows(sections) {
102
- const rows = [['board', 'quantity', 'name', 'setCode', 'cn', 'finish']];
103
- for (const board of ['commander', ...ALL_BOARDS]) {
104
- for (const entry of sections[board] || []) {
105
- rows.push([
106
- board,
107
- entry.qty,
108
- entry.name,
109
- clean(entry.card?.setCode || entry.card?.set),
110
- collectorNumber(entry.card),
111
- clean(entry.card?.finish || 'normal'),
112
- ]);
113
- }
114
- }
115
- return rows.map(row => row.map(csvCell).join(',')).join('\n');
116
- }
117
-
118
- export function defaultDeckExportOptions(preset) {
119
- const normalized = normalizePreset(preset);
120
- return {
121
- preset: normalized,
122
- boards: selectedBoardsForPreset(normalized, {}),
123
- includeCommander: true,
124
- collapsePrintings: !['moxfield', 'csv', 'json'].includes(normalized),
125
- };
126
- }
127
-
128
- export function buildDeckExportSections(list, deckMeta = {}, options = {}) {
129
- const preset = normalizePreset(options.preset);
130
- const selectedBoards = selectedBoardsForPreset(preset, options);
131
- const warnings = [];
132
- const sections = { commander: [], main: [], sideboard: [], maybe: [] };
133
-
134
- for (const card of list || []) {
135
- const name = cardName(card);
136
- if (!name) continue;
137
- const board = deckBoard(card);
138
- if (!selectedBoards.includes(board)) continue;
139
- sections[board].push(cloneEntry(card));
140
- }
141
-
142
- if (options.includeCommander !== false) {
143
- const commander = extractCommanderSlot('commander', deckMeta?.commander, sections.main, warnings);
144
- const partner = extractCommanderSlot('partner', deckMeta?.partner, sections.main, warnings);
145
- sections.commander = [commander, partner].filter(Boolean);
146
- sections.main = sections.main.filter(entry => entry.qty > 0);
147
- }
148
-
149
- if (options.collapsePrintings ?? defaultDeckExportOptions(preset).collapsePrintings) {
150
- for (const board of ALL_BOARDS) sections[board] = collapseByName(sections[board]);
151
- }
152
-
153
- return { sections, warnings, boards: selectedBoards, preset };
154
- }
155
-
156
- export function formatDeckTextLine(card, options = {}) {
157
- const preset = normalizePreset(options.preset);
158
- const qty = quantity(card);
159
- const name = cardName(card);
160
- if (preset !== 'moxfield') return `${qty} ${name}`;
161
-
162
- const parts = [`${qty} ${name}`];
163
- const set = setCode(card);
164
- const cn = collectorNumber(card);
165
- if (set && cn) parts.push(`(${set}) ${cn}`);
166
- const marker = finishMarker(card);
167
- if (marker) parts.push(marker);
168
- return parts.join(' ');
169
- }
170
-
171
- export function buildDeckExport(list, deckMeta = {}, options = {}) {
172
- const preset = normalizePreset(options.preset);
173
- const opts = { ...defaultDeckExportOptions(preset), ...options, preset };
174
- const { sections, warnings } = buildDeckExportSections(list, deckMeta, opts);
175
- const filenameBase = titleSlug(deckMeta);
176
-
177
- if (preset === 'json') {
178
- const output = {
179
- preset,
180
- metadata: { ...deckMeta },
181
- boards: Object.fromEntries(
182
- ['commander', ...ALL_BOARDS].map(board => [
183
- board,
184
- (sections[board] || []).map(entry => ({ quantity: entry.qty, name: entry.name, card: entry.card })),
185
- ]),
186
- ),
187
- warnings,
188
- };
189
- const body = JSON.stringify(output, null, 2);
190
- return { body, mime: 'application/json', filename: `${filenameBase}.json`, output, warnings };
191
- }
192
-
193
- if (preset === 'csv') {
194
- const body = csvRows(sections);
195
- return { body, mime: 'text/csv', filename: `${filenameBase}.csv`, output: sections, warnings };
196
- }
197
-
198
- const lines = [];
199
- if (sections.commander.length) {
200
- lines.push(...sectionLines('Commander', entriesForText(sections.commander, preset), opts), '');
201
- }
202
-
203
- if (preset === 'arena') {
204
- lines.push(...sectionLines('Deck', entriesForText(sections.main, preset), opts));
205
- if (sections.sideboard.length) lines.push('', ...sectionLines('Sideboard', entriesForText(sections.sideboard, preset), opts));
206
- } else if (preset === 'mtgo') {
207
- lines.push(...entriesForText(sections.main, preset).map(entry => formatDeckTextLine({ ...entry.card, qty: entry.qty }, opts)));
208
- lines.push(...entriesForText(sections.sideboard, preset).map(entry => `SB: ${formatDeckTextLine({ ...entry.card, qty: entry.qty }, opts)}`));
209
- } else {
210
- lines.push(...sectionLines('Mainboard', entriesForText(sections.main, preset), opts));
211
- if (sections.sideboard.length) lines.push('', ...sectionLines('Sideboard', entriesForText(sections.sideboard, preset), opts));
212
- if ((preset === 'moxfield' || opts.boards.includes('maybe')) && sections.maybe.length) {
213
- lines.push('', ...sectionLines('Maybeboard', entriesForText(sections.maybe, preset), opts));
214
- }
215
- }
216
-
217
- const body = lines.join('\n').replace(/\n+$/g, '');
218
- return { body, mime: 'text/plain', filename: `${filenameBase}-${preset}.txt`, output: sections, warnings };
219
- }
@@ -1,22 +0,0 @@
1
- import { collectionKey } from './collection.js';
2
- import { mergeSource } from './adapters.js';
3
-
4
- // Pure: takes (existing, imported) -> new collection. Dedupes by collectionKey,
5
- // sums qty on collisions, unions tags on collisions, merges per-format
6
- // `_source` metadata so re-imports don't drop earlier preserved fields.
7
- export function mergeIntoCollection(existing, imported) {
8
- const byKey = new Map();
9
- for (const c of existing) byKey.set(collectionKey(c), c);
10
- for (const c of imported) {
11
- const k = collectionKey(c);
12
- if (byKey.has(k)) {
13
- const e = byKey.get(k);
14
- e.qty += c.qty;
15
- e.tags = [...new Set([...(e.tags || []), ...(c.tags || [])])];
16
- mergeSource(e, c);
17
- } else {
18
- byKey.set(k, c);
19
- }
20
- }
21
- return Array.from(byKey.values());
22
- }
@@ -1,119 +0,0 @@
1
- import { makeEntry, normalizeTag } from './collection.js';
2
-
3
- // ---- CSV parser (handles quoted fields) ----
4
- export function parseCsv(text) {
5
- const rows = [];
6
- let row = [];
7
- let cell = '';
8
- let inQuotes = false;
9
- for (let i = 0; i < text.length; i++) {
10
- const ch = text[i];
11
- if (inQuotes) {
12
- if (ch === '"') {
13
- if (text[i + 1] === '"') { cell += '"'; i++; }
14
- else inQuotes = false;
15
- } else {
16
- cell += ch;
17
- }
18
- } else {
19
- if (ch === '"') inQuotes = true;
20
- else if (ch === ',') { row.push(cell); cell = ''; }
21
- else if (ch === '\n' || ch === '\r') {
22
- if (ch === '\r' && text[i + 1] === '\n') i++;
23
- row.push(cell); cell = '';
24
- if (row.length > 1 || row[0] !== '') rows.push(row);
25
- row = [];
26
- } else {
27
- cell += ch;
28
- }
29
- }
30
- }
31
- if (cell || row.length) { row.push(cell); rows.push(row); }
32
- return rows.filter(r => r.some(c => c !== ''));
33
- }
34
-
35
- // ---- Header alias mapping ----
36
- export const ALIASES = {
37
- name: ['name', 'card name', 'card'],
38
- setCode: ['set code', 'set', 'edition', 'setcode', 'set_code'],
39
- setName: ['set name', 'setname', 'edition name'],
40
- cn: ['collector number', 'card number', 'cn', 'collector_number', 'number'],
41
- finish: ['foil', 'finish', 'printing'],
42
- qty: ['quantity', 'count', 'qty'],
43
- condition: ['condition'],
44
- language: ['language', 'lang'],
45
- location: ['location', 'place', 'storage', 'where'],
46
- scryfallId: ['scryfall id', 'scryfall_id', 'scryfallid'],
47
- rarity: ['rarity'],
48
- price: ['purchase price', 'price', 'tcg market price'],
49
- tags: ['tags'],
50
- };
51
-
52
- export function mapHeaders(headerRow) {
53
- const idx = {};
54
- const lower = headerRow.map(h => h.toLowerCase().trim());
55
- for (const [key, aliases] of Object.entries(ALIASES)) {
56
- for (const a of aliases) {
57
- const i = lower.indexOf(a);
58
- if (i !== -1) { idx[key] = i; break; }
59
- }
60
- }
61
- return idx;
62
- }
63
-
64
- export function parseDecklist(text, options = {}) {
65
- const { location = '' } = options;
66
- const entries = [];
67
- const errors = [];
68
- const lines = text.split(/\r?\n/);
69
- for (let i = 0; i < lines.length; i++) {
70
- const line = lines[i].trim();
71
- if (!line || line.startsWith('//')) continue;
72
- const match = line.match(/^(\d+)\s+(.+?)\s+\(([^)]+)\)\s+(\S+)(?:\s+(.*))?$/);
73
- if (!match) {
74
- errors.push(i + 1);
75
- continue;
76
- }
77
- const [, qty, name, setCode, cn, markerText = ''] = match;
78
- const markers = markerText.toUpperCase();
79
- const finish = markers.includes('*E*') ? 'etched' : markers.includes('*F*') ? 'foil' : 'normal';
80
- entries.push(makeEntry({ qty, name, setCode, cn, finish, location }));
81
- }
82
- return { entries, errors };
83
- }
84
-
85
- // ---- Tags CSV cell helpers ----
86
- // Pipe-delimited. Inside a tag, '\' escapes itself ('\\') and '|' ('\|').
87
- // Walk char-by-char so escapes can't be ambiguated by a tag literally
88
- // ending in backslash (the bug was: ['foo\\', 'bar'] would naively
89
- // serialize as 'foo\|bar' and round-trip back as the single tag 'foo|bar').
90
- export function parseTagsCell(cell) {
91
- if (!cell) return [];
92
- const tags = [];
93
- let cur = '';
94
- for (let i = 0; i < cell.length; i++) {
95
- const ch = cell[i];
96
- if (ch === '\\' && i + 1 < cell.length) {
97
- const next = cell[i + 1];
98
- if (next === '\\' || next === '|') {
99
- cur += next;
100
- i++;
101
- continue;
102
- }
103
- }
104
- if (ch === '|') {
105
- tags.push(cur);
106
- cur = '';
107
- } else {
108
- cur += ch;
109
- }
110
- }
111
- tags.push(cur);
112
- return tags.map(s => normalizeTag(s)).filter(Boolean);
113
- }
114
-
115
- export function serializeTagsCell(tags) {
116
- if (!Array.isArray(tags) || tags.length === 0) return '';
117
- // Escape '\' first, then '|'. Order matters.
118
- return tags.map(t => String(t).replace(/\\/g, '\\\\').replace(/\|/g, '\\|')).join('|');
119
- }
@@ -1,151 +0,0 @@
1
- import { ensureContainersForCollection } from './collection.js';
2
- import { applyLoadedState } from './state.js';
3
- import { normalizeStoredAppData, serializeAppState } from './storageSchema.js';
4
-
5
- export const PORTABLE_ARCHIVE_KIND = 'mtgcollection.archive';
6
- export const PORTABLE_ARCHIVE_VERSION = 1;
7
-
8
- function isPlainObject(value) {
9
- return value && typeof value === 'object' && !Array.isArray(value);
10
- }
11
-
12
- function cloneJson(value, fallback) {
13
- if (value == null) return fallback;
14
- try {
15
- return JSON.parse(JSON.stringify(value));
16
- } catch (e) {
17
- return fallback;
18
- }
19
- }
20
-
21
- export function normalizeHistory(raw) {
22
- if (!Array.isArray(raw)) return [];
23
- return raw
24
- .filter(isPlainObject)
25
- .map(ev => cloneJson(ev, null))
26
- .filter(Boolean);
27
- }
28
-
29
- export function normalizeShareRecords(raw) {
30
- if (!Array.isArray(raw)) return [];
31
- return raw
32
- .filter(isPlainObject)
33
- .map(record => ({
34
- shareId: String(record.shareId || record.id || '').trim(),
35
- containerKey: String(record.containerKey || '').trim(),
36
- kind: String(record.kind || 'deck'),
37
- owned: Boolean(record.owned),
38
- createdAt: Number.isFinite(record.createdAt) ? record.createdAt : null,
39
- updatedAt: Number.isFinite(record.updatedAt) ? record.updatedAt : null,
40
- }))
41
- .filter(record => record.shareId);
42
- }
43
-
44
- export function extractShareRecords(appData) {
45
- const out = [];
46
- const containers = isPlainObject(appData?.containers) ? appData.containers : {};
47
- for (const [containerKey, container] of Object.entries(containers)) {
48
- if (!container || container.type !== 'deck' || !container.shareId) continue;
49
- out.push({
50
- shareId: String(container.shareId),
51
- containerKey,
52
- kind: 'deck',
53
- owned: false,
54
- createdAt: container.createdAt || null,
55
- updatedAt: container.updatedAt || null,
56
- });
57
- }
58
- return out;
59
- }
60
-
61
- export function makeSyncSnapshot({ app, history = [], shares = [] } = {}) {
62
- const appData = normalizeStoredAppData(app);
63
- if (!appData) return null;
64
- return {
65
- app: appData,
66
- history: normalizeHistory(history),
67
- shares: normalizeShareRecords(shares.length ? shares : extractShareRecords(appData)),
68
- };
69
- }
70
-
71
- export function captureSyncSnapshot(stateRef, { history = [] } = {}) {
72
- return makeSyncSnapshot({
73
- app: serializeAppState(stateRef),
74
- history,
75
- });
76
- }
77
-
78
- export function buildPortableArchive({ stateRef = null, snapshot = null, history = [] } = {}) {
79
- const syncSnapshot = snapshot
80
- ? makeSyncSnapshot(snapshot)
81
- : captureSyncSnapshot(stateRef, { history });
82
- if (!syncSnapshot) return null;
83
- return {
84
- kind: PORTABLE_ARCHIVE_KIND,
85
- version: PORTABLE_ARCHIVE_VERSION,
86
- exportedAt: new Date().toISOString(),
87
- snapshot: syncSnapshot,
88
- };
89
- }
90
-
91
- export function normalizePortableArchive(raw) {
92
- if (!isPlainObject(raw)) return null;
93
- if (raw.kind === PORTABLE_ARCHIVE_KIND) {
94
- const version = Number(raw.version || 1);
95
- if (version !== PORTABLE_ARCHIVE_VERSION) return null;
96
- const snapshot = makeSyncSnapshot(raw.snapshot || raw);
97
- if (!snapshot) return null;
98
- return {
99
- kind: PORTABLE_ARCHIVE_KIND,
100
- version: PORTABLE_ARCHIVE_VERSION,
101
- exportedAt: typeof raw.exportedAt === 'string' ? raw.exportedAt : '',
102
- snapshot,
103
- };
104
- }
105
-
106
- // Accept the existing raw localStorage shape as a convenience import.
107
- const legacyApp = normalizeStoredAppData(raw.app || raw);
108
- if (!legacyApp) return null;
109
- return buildPortableArchive({
110
- snapshot: {
111
- app: legacyApp,
112
- history: normalizeHistory(raw.history),
113
- shares: normalizeShareRecords(raw.shares),
114
- },
115
- });
116
- }
117
-
118
- export function portableArchiveToJson(archive) {
119
- return JSON.stringify(archive, null, 2);
120
- }
121
-
122
- export function parsePortableArchiveJson(text) {
123
- try {
124
- return normalizePortableArchive(JSON.parse(text));
125
- } catch (e) {
126
- return null;
127
- }
128
- }
129
-
130
- export function applySyncSnapshotToState(snapshot, {
131
- applyLoadedStateImpl = applyLoadedState,
132
- ensureContainersForCollectionImpl = ensureContainersForCollection,
133
- replaceHistoryImpl = null,
134
- } = {}) {
135
- const normalized = makeSyncSnapshot(snapshot);
136
- if (!normalized) return false;
137
- const app = normalized.app;
138
- applyLoadedStateImpl({
139
- collection: app.collection,
140
- containers: app.containers,
141
- viewMode: app.ui.viewMode,
142
- activeLocation: null,
143
- viewAsList: app.ui.viewAsList,
144
- selectedFormat: app.ui.selectedFormat,
145
- sortField: app.ui.sortField,
146
- sortDir: app.ui.sortDir,
147
- });
148
- ensureContainersForCollectionImpl();
149
- if (typeof replaceHistoryImpl === 'function') replaceHistoryImpl(normalized.history);
150
- return true;
151
- }