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.
- package/README.md +44 -70
- package/package.json +17 -27
- package/src/__tests__/args.test.js +39 -0
- package/src/__tests__/csv.test.js +33 -0
- package/src/__tests__/mcp.test.js +84 -0
- package/src/__tests__/mutate.test.js +91 -0
- package/src/__tests__/render.test.js +103 -0
- package/src/args.mjs +29 -6
- package/src/cli.mjs +38 -25
- package/src/commands/add.mjs +30 -51
- package/src/commands/deck.mjs +13 -47
- package/src/commands/edit.mjs +26 -38
- package/src/commands/export.mjs +57 -41
- package/src/commands/history.mjs +24 -0
- package/src/commands/import.mjs +52 -80
- package/src/commands/index.mjs +44 -20
- package/src/commands/login.mjs +29 -38
- package/src/commands/logout.mjs +13 -10
- package/src/commands/ls.mjs +13 -25
- package/src/commands/move.mjs +15 -35
- package/src/commands/prices.mjs +21 -0
- package/src/commands/recover.mjs +53 -0
- package/src/commands/resolve.mjs +37 -0
- package/src/commands/rm.mjs +15 -32
- package/src/commands/search.mjs +25 -35
- package/src/commands/summary.mjs +25 -28
- package/src/commands/undo.mjs +13 -28
- package/src/commands/value.mjs +30 -0
- package/src/commands/whoami.mjs +27 -19
- package/src/constants.mjs +34 -7
- package/src/csv.mjs +71 -0
- package/src/errors.mjs +13 -2
- package/src/mcp.mjs +227 -0
- package/src/mutate.mjs +142 -67
- package/src/oauth.mjs +200 -62
- package/src/output.mjs +27 -18
- package/src/render.mjs +135 -30
- package/src/store.mjs +39 -23
- package/src/api.mjs +0 -110
- package/src/commands/container.mjs +0 -83
- package/src/commands/deckExport.mjs +0 -48
- package/src/commands/show.mjs +0 -33
- package/src/commands/tag.mjs +0 -40
- package/src/commands/writeHelpers.mjs +0 -82
- package/src/scryfall.mjs +0 -81
- package/src/snapshot.mjs +0 -77
- package/vendor/README.md +0 -20
- package/vendor/adapters.js +0 -443
- package/vendor/collection.js +0 -665
- package/vendor/deckExport.js +0 -219
- package/vendor/importMerge.js +0 -22
- package/vendor/importParsing.js +0 -119
- package/vendor/portableArchive.js +0 -151
- package/vendor/searchCore.js +0 -223
- package/vendor/state.js +0 -91
- package/vendor/storageSchema.js +0 -75
- 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,
|
|
5
|
-
import {
|
|
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
|
|
10
|
-
const
|
|
11
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
26
|
-
out.line(' --no-color
|
|
27
|
-
out.line(' --api <
|
|
28
|
-
out.line(' --help
|
|
29
|
-
out.line(' --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('
|
|
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')) {
|
|
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 (
|
|
45
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
package/src/commands/add.mjs
CHANGED
|
@@ -1,57 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
};
|
package/src/commands/deck.mjs
CHANGED
|
@@ -1,52 +1,18 @@
|
|
|
1
|
-
import {
|
|
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: '
|
|
42
|
-
help: [
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
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
|
};
|
package/src/commands/edit.mjs
CHANGED
|
@@ -1,45 +1,33 @@
|
|
|
1
|
-
import {
|
|
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
|
|
9
|
-
help: [
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
if (
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
};
|
package/src/commands/export.mjs
CHANGED
|
@@ -1,56 +1,72 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
|
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
|
|
13
|
-
help: [
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
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
|
-
|
|
33
|
+
const rows = [];
|
|
27
34
|
let meta;
|
|
28
|
-
if (
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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,
|
|
48
|
-
out.emit({ file,
|
|
49
|
-
|
|
50
|
-
|
|
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(
|
|
68
|
+
ctx.out.raw(payload.replace(/\n$/, ''));
|
|
53
69
|
}
|
|
54
|
-
return
|
|
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
|
+
};
|
package/src/commands/import.mjs
CHANGED
|
@@ -1,94 +1,66 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
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
|
-
//
|
|
13
|
-
// and
|
|
14
|
-
const
|
|
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
|
|
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 {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
};
|