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/cli.mjs CHANGED
@@ -1,14 +1,17 @@
1
1
  import { parseArgs, boolFlag, strFlag } from './args.mjs';
2
2
  import { createOutput } from './output.mjs';
3
3
  import { CliError } from './errors.mjs';
4
- import { EXIT, VERSION, DEFAULT_API_BASE } from './constants.mjs';
5
- import { loadCredentials, saveCredentials, loadConfig } from './store.mjs';
6
- import { Session } from './api.mjs';
4
+ import { EXIT, VERSION, DEFAULT_API_ORIGIN } from './constants.mjs';
5
+ import { loadConfig } from './store.mjs';
7
6
  import { commands, commandOrder } from './commands/index.mjs';
8
7
 
9
- function resolveApiBase(flags) {
10
- const base = strFlag(flags, 'api') || process.env.BIBLIOPLEX_API_BASE || loadConfig().apiBase || DEFAULT_API_BASE;
11
- return base.replace(/\/+$/, '');
8
+ function resolveApiOrigin(flags) {
9
+ const origin =
10
+ strFlag(flags, 'api') ||
11
+ process.env.BIBLIOPLEX_API_ORIGIN ||
12
+ loadConfig().apiOrigin ||
13
+ DEFAULT_API_ORIGIN;
14
+ return origin.replace(/\/+$/, '');
12
15
  }
13
16
 
14
17
  function printHelp(out) {
@@ -16,19 +19,22 @@ function printHelp(out) {
16
19
  out.line('');
17
20
  out.line('usage: bp <command> [options]');
18
21
  out.line('');
19
- out.line('commands:');
20
- for (const name of commandOrder) {
21
- out.line(' ' + name.padEnd(12) + commands[name].summary);
22
+ if (commandOrder.length) {
23
+ out.line('commands:');
24
+ for (const name of commandOrder) {
25
+ out.line(' ' + name.padEnd(12) + commands[name].summary);
26
+ }
27
+ out.line('');
22
28
  }
23
- out.line('');
24
29
  out.line('global options:');
25
- out.line(' --json machine-readable output ({ok,data}|{ok,error})');
26
- out.line(' --no-color disable colored output');
27
- out.line(' --api <url> override the API base url');
28
- out.line(' --help show help (also: bp <command> --help)');
29
- out.line(' --version print the version');
30
+ out.line(' --json machine-readable output ({ok,data}|{ok,error})');
31
+ out.line(' --no-color disable colored output');
32
+ out.line(' --api <origin> override the API origin (or set BIBLIOPLEX_API_ORIGIN)');
33
+ out.line(' --help show help (also: bp <command> --help)');
34
+ out.line(' --version print the version');
30
35
  out.line('');
31
- out.line('start with: bp login');
36
+ out.line('auth: run `bp login`, or set BIBLIOPLEX_TOKEN to a personal access');
37
+ out.line('token for headless / CI use.');
32
38
  }
33
39
 
34
40
  export async function run(argv) {
@@ -36,13 +42,18 @@ export async function run(argv) {
36
42
  const out = createOutput({ json: boolFlag(flags, 'json'), color: !boolFlag(flags, 'no-color') });
37
43
  const command = positionals[0];
38
44
 
39
- if (!command) {
40
- if (boolFlag(flags, 'version')) { out.line(VERSION); return EXIT.OK; }
45
+ if (!command || command === 'help') {
46
+ if (boolFlag(flags, 'version')) {
47
+ out.line(VERSION);
48
+ return EXIT.OK;
49
+ }
41
50
  printHelp(out);
42
51
  return EXIT.OK;
43
52
  }
44
- if (command === 'help') { printHelp(out); return EXIT.OK; }
45
- if (boolFlag(flags, 'version')) { out.line(VERSION); return EXIT.OK; }
53
+ if (boolFlag(flags, 'version')) {
54
+ out.line(VERSION);
55
+ return EXIT.OK;
56
+ }
46
57
 
47
58
  const cmd = commands[command];
48
59
  if (!cmd) {
@@ -58,11 +69,13 @@ export async function run(argv) {
58
69
  out,
59
70
  flags,
60
71
  args: positionals.slice(1),
61
- apiBase: resolveApiBase(flags),
62
- makeSession() {
63
- const creds = loadCredentials();
64
- if (!creds?.accessToken) throw new CliError('not logged in — run `bp login`', EXIT.AUTH);
65
- return new Session({ base: this.apiBase, credentials: creds, persist: saveCredentials });
72
+ apiOrigin: resolveApiOrigin(flags),
73
+ // Lazily build an MCP session. Uses BIBLIOPLEX_TOKEN (a personal access
74
+ // token) when set, otherwise the stored OAuth credentials. Imported on
75
+ // demand so `help`/`version` work without auth wiring.
76
+ async makeSession({ write = false } = {}) {
77
+ const { createSession } = await import('./mcp.mjs');
78
+ return createSession({ origin: this.apiOrigin, requireWrite: write, out: this.out });
66
79
  },
67
80
  };
68
81
 
@@ -1,57 +1,36 @@
1
- import { resolvePrinting, cardToFields } from '../scryfall.mjs';
2
- import { normalizeCollectionEntry } from '../../vendor/collection.js';
3
- import { mergeIntoCollection } from '../../vendor/importMerge.js';
4
- import { applyMutation } from '../mutate.mjs';
5
- import { strFlag, intFlag, boolFlag } from '../args.mjs';
6
- import { requireWrite, parseLocationFlag, ensureContainer, persistUndo, printWrite } from './writeHelpers.mjs';
7
- import { usageError, CliError } from '../errors.mjs';
1
+ import { intFlag, strFlag } from '../args.mjs';
2
+ import { usageError } from '../errors.mjs';
3
+ import { runMutation, resolveOnePrinting } from '../mutate.mjs';
8
4
 
9
5
  export default {
10
6
  summary: 'add a card to your collection',
11
- help: [
12
- 'usage: bp add <name> [--set xxx --cn 123] [--finish foil] [--condition lightly_played]',
13
- ' [--qty 2] [--location "deck:breya"] [--tags trade,wishlist] [--dry-run]',
14
- ' bp add --scryfall-id <id> [...]',
15
- '',
16
- 'resolves the printing on scryfall (give --set + --cn to pin an exact printing),',
17
- 'then adds it to your cloud collection. coalesces with an identical existing stack.',
18
- ].join('\n'),
19
- async run(ctx) {
20
- const { out, flags, args } = ctx;
21
- const session = ctx.makeSession();
22
- requireWrite(session);
23
-
24
- const scryfallId = strFlag(flags, 'scryfall-id', 'id');
25
- const set = strFlag(flags, 'set', 's');
26
- const cn = strFlag(flags, 'cn');
27
- const name = args.join(' ').trim();
28
- if (!scryfallId && !(set && cn) && !name) throw usageError('usage: bp add <name> [--set --cn] | --scryfall-id <id>');
29
-
30
- const finish = strFlag(flags, 'finish') || 'normal';
31
- const card = await resolvePrinting({ scryfallId, set, cn, name });
32
- if (!card) throw new CliError("couldn't find that printing — try specifying --set and --cn");
7
+ help: `bp add <name> [--set CODE] [--cn NUMBER] [--finish foil|nonfoil|etched] [--qty N] [--to LOCATION] [--tag a,b] [--yes] [--dry-run]
33
8
 
34
- const tags = strFlag(flags, 'tags', 'tag');
35
- const entry = normalizeCollectionEntry({
36
- ...cardToFields(card, finish),
37
- finish,
38
- qty: intFlag(flags, 'qty', 1) || 1,
39
- condition: strFlag(flags, 'condition', 'cond') || 'near_mint',
40
- language: strFlag(flags, 'lang', 'language') || 'en',
41
- location: parseLocationFlag(strFlag(flags, 'location', 'loc', 'to')),
42
- tags: tags ? tags.split(',').map(s => s.trim()).filter(Boolean) : [],
43
- }, { preserveResolvedFields: true });
44
-
45
- const result = await applyMutation(session, (draft) => {
46
- ensureContainer(draft, entry.location);
47
- draft.app.collection = mergeIntoCollection(draft.app.collection, [entry]);
48
- return { added: { name: entry.name, set: entry.setCode.toUpperCase(), cn: entry.cn, finish: entry.finish, qty: entry.qty } };
49
- }, { dryRun: boolFlag(flags, 'dry-run') });
50
-
51
- persistUndo(result);
52
- printWrite(out, result, () => out.info(
53
- out.c.green('✓ added') + ` ${entry.qty}x ${entry.name} (${entry.setCode.toUpperCase()} ${entry.cn}${entry.finish !== 'normal' ? ' ' + entry.finish : ''})`
54
- + (entry.location ? ` → ${entry.location.type}:${entry.location.name}` : '')));
55
- return 0;
9
+ Resolves a printing, then adds it. For an exact printing use --set + --cn.
10
+ Examples:
11
+ bp add "sol ring" --to deck:breya
12
+ bp add --set sld --cn 2144 --finish foil --qty 2`,
13
+ async run(ctx) {
14
+ const session = await ctx.makeSession({ write: true });
15
+ const query = ctx.args.join(' ').trim();
16
+ const setCode = strFlag(ctx.flags, 'set');
17
+ const cn = strFlag(ctx.flags, 'cn');
18
+ const finish = strFlag(ctx.flags, 'finish');
19
+ if (!query && !(setCode && cn)) {
20
+ throw usageError('usage: bp add <name> (or --set CODE --cn NUMBER)');
21
+ }
22
+ const { scryfallId } = await resolveOnePrinting(session, { query, setCode, cn, finish });
23
+ const args = { operation: 'add', scryfallId, qty: intFlag(ctx.flags, 'qty', 1) };
24
+ if (finish) args.finish = finish;
25
+ const to = strFlag(ctx.flags, 'to');
26
+ if (to) args.location = to;
27
+ const tagStr = strFlag(ctx.flags, 'tag', 'tags');
28
+ if (tagStr) {
29
+ args.tags = tagStr
30
+ .split(',')
31
+ .map((s) => s.trim())
32
+ .filter(Boolean);
33
+ }
34
+ return runMutation(ctx, session, args, { verb: 'add' });
56
35
  },
57
36
  };
@@ -1,52 +1,18 @@
1
- import { loadSnapshot } from '../mutate.mjs';
2
- import { findContainer, containerCards } from '../snapshot.mjs';
3
- import { cardRow, CARD_COLUMNS } from '../render.mjs';
4
- import { usageError, CliError } from '../errors.mjs';
5
- import { runDeckExport } from './deckExport.mjs';
6
-
7
- const BOARDS = ['main', 'sideboard', 'maybe'];
8
-
9
- async function deckShow(ctx, name) {
10
- const { out } = ctx;
11
- if (!name) throw usageError('usage: bp deck show <name>');
12
- const session = ctx.makeSession();
13
- const { snapshot } = await loadSnapshot(session);
14
- const { matches } = findContainer(snapshot, 'deck:' + name);
15
- const decks = matches.filter(m => m.type === 'deck');
16
- if (!decks.length) throw new CliError(`no deck named "${name}"`);
17
-
18
- const deck = decks[0];
19
- const cards = containerCards(snapshot, deck);
20
- const boards = { main: [], sideboard: [], maybe: [] };
21
- for (const c of cards) (boards[c.deckBoard] || boards.main).push(c);
22
-
23
- out.emit({ deck: { name: deck.name, meta: deck.deck || null }, boards }, () => {
24
- out.line(out.c.bold('deck: ' + deck.name));
25
- const meta = deck.deck || {};
26
- if (meta.format) out.line(' format: ' + meta.format);
27
- if (meta.commander?.name) out.line(' commander: ' + meta.commander.name);
28
- for (const b of BOARDS) {
29
- const list = boards[b];
30
- if (!list.length) continue;
31
- const n = list.reduce((s, c) => s + (parseInt(c.qty, 10) || 0), 0);
32
- out.line('');
33
- out.line(out.c.bold(`${b} (${n})`));
34
- out.table(CARD_COLUMNS, list.map(cardRow));
35
- }
36
- });
37
- return 0;
38
- }
1
+ import { emitResult, DECKLIST_COLUMNS, CONTAINER_COLUMNS } from '../render.mjs';
39
2
 
40
3
  export default {
41
- summary: 'inspect or export a deck',
42
- help: [
43
- 'usage: bp deck show <name>',
44
- ' bp deck export <name> [--preset plain|moxfield|arena|mtgo|csv|json] [--boards main,sideboard,maybe]',
45
- ].join('\n'),
4
+ summary: 'list decks or show a decklist',
5
+ help: `bp deck [name]
6
+
7
+ With no name, lists your decks/containers. With a deck name, shows its decklist.`,
46
8
  async run(ctx) {
47
- const [sub, ...rest] = ctx.args;
48
- if (sub === 'show') return deckShow(ctx, rest.join(' '));
49
- if (sub === 'export') return runDeckExport(ctx, rest);
50
- throw usageError('usage: bp deck <show|export> <name>');
9
+ const session = await ctx.makeSession();
10
+ const name = ctx.args.join(' ').trim();
11
+ if (!name) {
12
+ const sc = await session.call('query_collection', { from: 'containers' });
13
+ return emitResult(ctx, sc, { columns: CONTAINER_COLUMNS });
14
+ }
15
+ const sc = await session.call('query_collection', { from: 'decklist', deckName: name });
16
+ return emitResult(ctx, sc, { columns: DECKLIST_COLUMNS });
51
17
  },
52
18
  };
@@ -1,45 +1,33 @@
1
- import { collectionKey, normalizeCondition, normalizeFinish } from '../../vendor/collection.js';
2
- import { applyMutation } from '../mutate.mjs';
3
- import { strFlag, intFlag, boolFlag } from '../args.mjs';
4
- import { requireWrite, selectorFrom, requireStacks, persistUndo, printWrite } from './writeHelpers.mjs';
1
+ import { strFlag } from '../args.mjs';
5
2
  import { usageError } from '../errors.mjs';
3
+ import { runMutation } from '../mutate.mjs';
6
4
 
7
5
  export default {
8
- summary: 'edit a stack (condition / finish / qty / tags)',
9
- help: [
10
- 'usage: bp edit <name> [--set --cn ...] [--condition lightly_played] [--finish foil]',
11
- ' [--qty 3] [--tags trade,foil] [--all] [--dry-run]',
12
- '',
13
- 'updates fields on a matching stack. --tags replaces the tag set ("" clears).',
14
- ].join('\n'),
6
+ summary: 'edit a card (finish, condition, tags)',
7
+ help: `bp edit <itemKey> [--finish ...] [--condition ...] [--tag a,b,c] [--yes] [--dry-run]
8
+
9
+ Edit an inventory row. Tags use set-semantics: pass the full desired list
10
+ (empty --tag clears them).`,
15
11
  async run(ctx) {
16
- const { out, flags, args } = ctx;
17
- const session = ctx.makeSession();
18
- requireWrite(session);
19
- const sel = selectorFrom(flags, args);
20
- const newCond = strFlag(flags, 'condition', 'cond');
21
- const newFinish = strFlag(flags, 'finish');
22
- const newQty = intFlag(flags, 'qty', null);
23
- const setTags = strFlag(flags, 'tags');
24
- if (newCond == null && newFinish == null && newQty == null && setTags == null) {
25
- throw usageError('nothing to edit — pass --condition, --finish, --qty, or --tags');
12
+ const itemKey = ctx.args[0] || strFlag(ctx.flags, 'item');
13
+ if (!itemKey)
14
+ throw usageError('usage: bp edit <itemKey> [--finish ...] [--condition ...] [--tag ...]');
15
+ const session = await ctx.makeSession({ write: true });
16
+ const args = { operation: 'edit', itemKey };
17
+ const finish = strFlag(ctx.flags, 'finish');
18
+ if (finish) args.finish = finish;
19
+ const condition = strFlag(ctx.flags, 'condition');
20
+ if (condition) args.condition = condition;
21
+ const tagStr = strFlag(ctx.flags, 'tag', 'tags');
22
+ if (tagStr != null) {
23
+ args.tags = tagStr
24
+ .split(',')
25
+ .map((s) => s.trim())
26
+ .filter(Boolean);
26
27
  }
27
-
28
- const result = await applyMutation(session, (draft) => {
29
- const stacks = requireStacks(draft.app.collection, sel, { all: boolFlag(flags, 'all') });
30
- const keys = new Set(stacks.map(collectionKey));
31
- for (const e of draft.app.collection) {
32
- if (!keys.has(collectionKey(e))) continue;
33
- if (newCond) e.condition = normalizeCondition(newCond);
34
- if (newFinish) e.finish = normalizeFinish(newFinish);
35
- if (newQty != null) e.qty = Math.max(1, newQty);
36
- if (setTags != null) e.tags = setTags ? setTags.split(',').map(s => s.trim()).filter(Boolean) : [];
37
- }
38
- return { edited: stacks.length };
39
- }, { dryRun: boolFlag(flags, 'dry-run') });
40
-
41
- persistUndo(result);
42
- printWrite(out, result, () => out.info(out.c.green('✓ edited') + ` ${result.meta.edited} stack(s)`));
43
- return 0;
28
+ if (!finish && !condition && tagStr == null) {
29
+ throw usageError('nothing to edit — pass --finish, --condition, or --tag');
30
+ }
31
+ return runMutation(ctx, session, args, { verb: 'edit' });
44
32
  },
45
33
  };
@@ -1,56 +1,72 @@
1
1
  import { writeFileSync } from 'node:fs';
2
- import { loadSnapshot } from '../mutate.mjs';
3
- import { runQuery, collectionOf } from '../snapshot.mjs';
4
- import { getAdapter } from '../../vendor/adapters.js';
5
- import { buildPortableArchive, portableArchiveToJson } from '../../vendor/portableArchive.js';
6
- import { strFlag, boolFlag } from '../args.mjs';
7
- import { usageError, CliError } from '../errors.mjs';
2
+ import { strFlag, boolFlag, intFlag } from '../args.mjs';
3
+ import { toCsv } from '../csv.mjs';
4
+ import { pickRows } from '../render.mjs';
5
+ import { EXIT } from '../constants.mjs';
8
6
 
9
- const CSV_FORMATS = ['canonical', 'moxfield', 'manabox', 'deckbox'];
7
+ const INVENTORY_EXPORT_COLUMNS = [
8
+ 'name',
9
+ 'setCode',
10
+ 'cn',
11
+ 'finish',
12
+ 'condition',
13
+ 'qty',
14
+ 'location',
15
+ 'tags',
16
+ 'price',
17
+ ];
10
18
 
11
19
  export default {
12
- summary: 'export your collection to csv / json',
13
- help: [
14
- 'usage: bp export [query] [--format canonical|moxfield|manabox|deckbox|json] [--output file]',
15
- ' bp export --archive [--output backup.json] (full round-trippable backup)',
16
- '',
17
- 'with a query, exports only the matching cards (e.g. bp export "f:foil" --format moxfield).',
18
- 'writes to stdout unless --output is given.',
19
- ].join('\n'),
20
+ summary: 'export your collection (CSV/JSON)',
21
+ help: `bp export [--deck NAME] [--format csv|json] [--all] [--output FILE]
22
+
23
+ Export your inventory (default) or a deck's list (--deck NAME). CSV by default.
24
+ --all pages the entire collection (otherwise one page). Writes to --output, or
25
+ stdout if omitted. The inventory CSV round-trips with \`bp import\`.`,
20
26
  async run(ctx) {
21
- const { out, flags, args } = ctx;
22
- const session = ctx.makeSession();
23
- const { snapshot } = await loadSnapshot(session);
24
- const file = strFlag(flags, 'output', 'o');
27
+ const session = await ctx.makeSession();
28
+ const deck = strFlag(ctx.flags, 'deck');
29
+ const format = (
30
+ strFlag(ctx.flags, 'format') || (boolFlag(ctx.flags, 'json') ? 'json' : 'csv')
31
+ ).toLowerCase();
25
32
 
26
- let body;
33
+ const rows = [];
27
34
  let meta;
28
- if (boolFlag(flags, 'archive')) {
29
- const archive = buildPortableArchive({ snapshot });
30
- if (!archive) throw new CliError('could not build archive');
31
- body = portableArchiveToJson(archive);
32
- meta = { kind: 'archive' };
35
+ if (deck) {
36
+ const sc = await session.call('query_collection', { from: 'decklist', deckName: deck });
37
+ rows.push(...pickRows(sc));
38
+ meta = { deck };
33
39
  } else {
34
- const cards = runQuery(collectionOf(snapshot), args.join(' '));
35
- const format = strFlag(flags, 'format') || 'canonical';
36
- if (format === 'json') {
37
- body = JSON.stringify(cards, null, 2);
38
- } else if (CSV_FORMATS.includes(format)) {
39
- body = getAdapter(format).export(cards);
40
- } else {
41
- throw usageError(`unknown format: ${format} (use ${CSV_FORMATS.join('/')}, json, or --archive)`);
42
- }
43
- meta = { format, count: cards.length };
40
+ const all = boolFlag(ctx.flags, 'all');
41
+ const limit = intFlag(ctx.flags, 'limit');
42
+ let cursor;
43
+ let pages = 0;
44
+ do {
45
+ const args = { from: 'inventory' };
46
+ if (limit) args.limit = limit;
47
+ if (cursor) args.cursor = cursor;
48
+ const sc = await session.call('query_collection', args);
49
+ rows.push(...pickRows(sc));
50
+ cursor = all ? sc?.nextCursor : undefined;
51
+ pages += 1;
52
+ } while (cursor && pages < 1000);
53
+ meta = { count: rows.length };
44
54
  }
45
55
 
56
+ const payload =
57
+ format === 'json'
58
+ ? JSON.stringify({ ...meta, rows }, null, 2) + '\n'
59
+ : toCsv(rows, deck ? undefined : INVENTORY_EXPORT_COLUMNS);
60
+
61
+ const file = strFlag(ctx.flags, 'output');
46
62
  if (file) {
47
- writeFileSync(file, body.endsWith('\n') ? body : body + '\n');
48
- out.emit({ file, ...meta }, () => out.info(`wrote ${file}`));
49
- } else if (out.json) {
50
- out.emit({ ...meta, body });
63
+ writeFileSync(file, payload);
64
+ ctx.out.emit({ written: file, count: rows.length }, () =>
65
+ ctx.out.info(`wrote ${rows.length} rows to ${file}`),
66
+ );
51
67
  } else {
52
- out.raw(body);
68
+ ctx.out.raw(payload.replace(/\n$/, ''));
53
69
  }
54
- return 0;
70
+ return EXIT.OK;
55
71
  },
56
72
  };
@@ -0,0 +1,24 @@
1
+ import { intFlag } from '../args.mjs';
2
+ import { emitResult } from '../render.mjs';
3
+
4
+ export default {
5
+ summary: 'recent collection changes',
6
+ help: `bp history [--limit N]
7
+
8
+ Recent change events for your collection.`,
9
+ async run(ctx) {
10
+ const session = await ctx.makeSession();
11
+ const args = { from: 'history' };
12
+ const limit = intFlag(ctx.flags, 'limit');
13
+ if (limit != null) args.limit = limit;
14
+ const sc = await session.call('query_collection', args);
15
+ return emitResult(ctx, sc, {
16
+ columns: [
17
+ { key: 'type', header: 'type' },
18
+ { key: 'summary', header: 'summary' },
19
+ { key: 'description', header: 'description' },
20
+ { key: 'at', header: 'when' },
21
+ ],
22
+ });
23
+ },
24
+ };
@@ -1,94 +1,66 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { parseCsv } from '../../vendor/importParsing.js';
3
- import { detectAdapter } from '../../vendor/adapters.js';
4
- import { mergeIntoCollection } from '../../vendor/importMerge.js';
5
- import { normalizeCollectionEntry } from '../../vendor/collection.js';
6
- import { resolvePrinting, cardToFields, sleep } from '../scryfall.mjs';
7
- import { applyMutation } from '../mutate.mjs';
8
- import { boolFlag } from '../args.mjs';
9
- import { requireWrite, ensureContainer, printWrite } from './writeHelpers.mjs';
2
+ import { strFlag } from '../args.mjs';
10
3
  import { usageError, CliError } from '../errors.mjs';
4
+ import { fromCsv } from '../csv.mjs';
5
+ import { runMutation } from '../mutate.mjs';
6
+ import { EXIT } from '../constants.mjs';
11
7
 
12
- // Pushed in chunks so a large import stays within the worker's per-request CPU
13
- // and payload limits; each chunk is one revision. Not undoable (too large).
14
- const CHUNK = 150;
8
+ // The server caps mutate_inventory at 200 items/call; keep v1 import to a single
9
+ // call and tell the user to split larger files (auto-chunking can come later).
10
+ const MAX_ROWS = 200;
15
11
 
16
12
  export default {
17
- summary: 'import cards from a csv file (moxfield/manabox/deckbox/canonical)',
18
- help: [
19
- 'usage: bp import <file.csv> [--no-resolve] [--dry-run]',
20
- '',
21
- 'auto-detects the csv format and merges the cards into your cloud collection.',
22
- 'by default each card is resolved on scryfall so it is fully searchable; pass',
23
- '--no-resolve to import as-is (faster, but no oracle/color/price data).',
24
- ].join('\n'),
25
- async run(ctx) {
26
- const { out, flags, args } = ctx;
27
- const session = ctx.makeSession();
28
- requireWrite(session);
29
- const path = args[0];
30
- if (!path) throw usageError('usage: bp import <file.csv>');
13
+ summary: 'import cards from a CSV file',
14
+ help: `bp import <file.csv> [--to LOCATION] [--yes] [--dry-run]
31
15
 
16
+ Bulk-add cards from CSV (previewed and confirmed like any write). Columns:
17
+ setCode (or set), cn, finish, qty, condition, tags (; or | separated), location.
18
+ --to overrides the location for every row. Up to ${MAX_ROWS} rows per file.`,
19
+ async run(ctx) {
20
+ const file = ctx.args[0] || strFlag(ctx.flags, 'input');
21
+ if (!file) throw usageError('usage: bp import <file.csv> [--to LOCATION]');
32
22
  let text;
33
- try { text = readFileSync(path, 'utf8'); } catch { throw new CliError('cannot read file: ' + path); }
34
- const rows = parseCsv(text);
35
- if (rows.length < 2) throw new CliError('no rows found in ' + path);
36
- const adapter = detectAdapter(rows[0]);
37
- if (!adapter) throw new CliError('could not detect a supported csv format (moxfield/manabox/deckbox/canonical)');
38
- let entries = adapter.parse(rows);
39
- if (!entries.length) throw new CliError('no cards parsed from ' + path);
40
-
41
- let resolved = 0;
42
- let unresolved = 0;
43
- if (!boolFlag(flags, 'no-resolve')) {
44
- out.info(`resolving ${entries.length} card(s) on scryfall…`);
45
- const next = [];
46
- for (let i = 0; i < entries.length; i += 1) {
47
- const e = entries[i];
48
- let card = null;
49
- try { card = await resolvePrinting({ scryfallId: e.scryfallId || null, set: e.setCode, cn: e.cn, name: e.name }); } catch { /* leave unresolved */ }
50
- if (card) {
51
- resolved += 1;
52
- next.push(normalizeCollectionEntry({
53
- ...cardToFields(card, e.finish), finish: e.finish, qty: e.qty,
54
- condition: e.condition, language: e.language, location: e.location, tags: e.tags,
55
- ...(e._source ? { _source: e._source } : {}),
56
- }, { preserveResolvedFields: true }));
57
- } else {
58
- unresolved += 1;
59
- next.push(e);
60
- }
61
- if ((i + 1) % 25 === 0) out.info(out.c.dim(` ${i + 1}/${entries.length}`));
62
- await sleep(80);
63
- }
64
- entries = next;
23
+ try {
24
+ text = readFileSync(file, 'utf8');
25
+ } catch (e) {
26
+ throw new CliError(`cannot read ${file}: ${e.message}`, EXIT.USAGE);
65
27
  }
66
-
67
- if (boolFlag(flags, 'dry-run')) {
68
- const result = await applyMutation(session, (draft) => {
69
- for (const e of entries) ensureContainer(draft, e.location);
70
- draft.app.collection = mergeIntoCollection(draft.app.collection, entries);
71
- return { format: adapter.id, imported: entries.length, resolved, unresolved };
72
- }, { dryRun: true });
73
- printWrite(out, result, () => out.info(out.c.dim(`dry run — would import ${entries.length} card(s) (${adapter.id})`)));
74
- return 0;
28
+ const rows = fromCsv(text);
29
+ if (!rows.length) throw new CliError(`no rows found in ${file}`, EXIT.USAGE);
30
+ if (rows.length > MAX_ROWS) {
31
+ throw new CliError(
32
+ `${rows.length} rows exceeds the ${MAX_ROWS}-row per-import cap — split the file`,
33
+ EXIT.USAGE,
34
+ );
75
35
  }
76
36
 
77
- let revision = 0;
78
- for (let i = 0; i < entries.length; i += CHUNK) {
79
- const chunk = entries.slice(i, i + CHUNK);
80
- const result = await applyMutation(session, (draft) => {
81
- for (const e of chunk) ensureContainer(draft, e.location);
82
- draft.app.collection = mergeIntoCollection(draft.app.collection, chunk);
83
- });
84
- revision = result.revision || revision;
85
- if (entries.length > CHUNK) out.info(out.c.dim(` pushed ${Math.min(i + CHUNK, entries.length)}/${entries.length}`));
86
- }
37
+ const to = strFlag(ctx.flags, 'to');
38
+ const items = rows.map((r) => {
39
+ const item = {};
40
+ const set = r.setCode || r.set;
41
+ if (set) item.setCode = set;
42
+ if (r.cn) item.cn = r.cn;
43
+ if (r.finish) item.finish = r.finish;
44
+ const qty = parseInt(r.qty, 10);
45
+ item.qty = Number.isFinite(qty) && qty > 0 ? qty : 1;
46
+ if (r.condition) item.condition = r.condition;
47
+ if (r.tags) {
48
+ item.tags = String(r.tags)
49
+ .split(/[;|]/)
50
+ .map((s) => s.trim())
51
+ .filter(Boolean);
52
+ }
53
+ const loc = to || r.location;
54
+ if (loc) item.location = loc;
55
+ return item;
56
+ });
87
57
 
88
- out.emit(
89
- { changed: true, imported: entries.length, format: adapter.id, resolved, unresolved, revision },
90
- () => out.info(out.c.green('✓ imported') + ` ${entries.length} card(s) (${adapter.id})` + (unresolved ? `, ${unresolved} unresolved` : '')),
58
+ const session = await ctx.makeSession({ write: true });
59
+ return runMutation(
60
+ ctx,
61
+ session,
62
+ { operation: 'add', items },
63
+ { verb: `import ${items.length} rows` },
91
64
  );
92
- return 0;
93
65
  },
94
66
  };