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/commands/index.mjs
CHANGED
|
@@ -1,40 +1,64 @@
|
|
|
1
|
-
|
|
2
|
-
import logout from './logout.mjs';
|
|
3
|
-
import whoami from './whoami.mjs';
|
|
4
|
-
import search from './search.mjs';
|
|
1
|
+
// Command registry. Each command is { summary, help?, run(ctx) }.
|
|
5
2
|
import summary from './summary.mjs';
|
|
3
|
+
import search from './search.mjs';
|
|
6
4
|
import ls from './ls.mjs';
|
|
7
|
-
import show from './show.mjs';
|
|
8
5
|
import deck from './deck.mjs';
|
|
6
|
+
import value from './value.mjs';
|
|
7
|
+
import prices from './prices.mjs';
|
|
8
|
+
import history from './history.mjs';
|
|
9
|
+
import resolve from './resolve.mjs';
|
|
10
|
+
import exportCmd from './export.mjs';
|
|
11
|
+
import importCmd from './import.mjs';
|
|
9
12
|
import add from './add.mjs';
|
|
10
13
|
import rm from './rm.mjs';
|
|
11
14
|
import move from './move.mjs';
|
|
12
15
|
import edit from './edit.mjs';
|
|
13
|
-
import tag from './tag.mjs';
|
|
14
|
-
import container from './container.mjs';
|
|
15
16
|
import undo from './undo.mjs';
|
|
16
|
-
import
|
|
17
|
-
import
|
|
17
|
+
import recover from './recover.mjs';
|
|
18
|
+
import login from './login.mjs';
|
|
19
|
+
import logout from './logout.mjs';
|
|
20
|
+
import whoami from './whoami.mjs';
|
|
18
21
|
|
|
19
|
-
// Registry. commandOrder controls help-listing order.
|
|
20
22
|
export const commands = {
|
|
21
|
-
login,
|
|
22
|
-
logout,
|
|
23
|
-
whoami,
|
|
24
|
-
search,
|
|
25
23
|
summary,
|
|
24
|
+
search,
|
|
26
25
|
ls,
|
|
27
|
-
show,
|
|
28
26
|
deck,
|
|
27
|
+
value,
|
|
28
|
+
prices,
|
|
29
|
+
history,
|
|
30
|
+
resolve,
|
|
31
|
+
export: exportCmd,
|
|
32
|
+
import: importCmd,
|
|
29
33
|
add,
|
|
30
34
|
rm,
|
|
31
35
|
move,
|
|
32
36
|
edit,
|
|
33
|
-
tag,
|
|
34
|
-
container,
|
|
35
37
|
undo,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
recover,
|
|
39
|
+
login,
|
|
40
|
+
logout,
|
|
41
|
+
whoami,
|
|
38
42
|
};
|
|
39
43
|
|
|
40
|
-
export const commandOrder =
|
|
44
|
+
export const commandOrder = [
|
|
45
|
+
'summary',
|
|
46
|
+
'search',
|
|
47
|
+
'ls',
|
|
48
|
+
'deck',
|
|
49
|
+
'value',
|
|
50
|
+
'prices',
|
|
51
|
+
'history',
|
|
52
|
+
'resolve',
|
|
53
|
+
'export',
|
|
54
|
+
'import',
|
|
55
|
+
'add',
|
|
56
|
+
'rm',
|
|
57
|
+
'move',
|
|
58
|
+
'edit',
|
|
59
|
+
'undo',
|
|
60
|
+
'recover',
|
|
61
|
+
'login',
|
|
62
|
+
'logout',
|
|
63
|
+
'whoami',
|
|
64
|
+
];
|
package/src/commands/login.mjs
CHANGED
|
@@ -1,48 +1,39 @@
|
|
|
1
|
-
import { login
|
|
1
|
+
import { login } from '../oauth.mjs';
|
|
2
2
|
import { saveCredentials } from '../store.mjs';
|
|
3
|
-
import {
|
|
4
|
-
import { READ_SCOPE, WRITE_SCOPE } from '../constants.mjs';
|
|
3
|
+
import { READ_SCOPE, WRITE_SCOPE, EXIT } from '../constants.mjs';
|
|
5
4
|
import { boolFlag } from '../args.mjs';
|
|
6
|
-
import { loadSnapshot } from '../mutate.mjs';
|
|
7
|
-
import { summarize, collectionOf } from '../snapshot.mjs';
|
|
8
5
|
|
|
9
6
|
export default {
|
|
10
|
-
summary: 'sign in
|
|
11
|
-
help: [
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
'',
|
|
17
|
-
' --write also request permission to make changes (add/edit/import)',
|
|
18
|
-
' --no-browser print the url to open manually instead of launching a browser',
|
|
19
|
-
].join('\n'),
|
|
20
|
-
async run(ctx) {
|
|
21
|
-
const { out, flags, apiBase } = ctx;
|
|
22
|
-
const scope = boolFlag(flags, 'write') ? `${READ_SCOPE} ${WRITE_SCOPE}` : READ_SCOPE;
|
|
23
|
-
const tokens = await oauthLogin({ base: apiBase, scope, out, noBrowser: boolFlag(flags, 'no-browser') });
|
|
7
|
+
summary: 'sign in via your browser',
|
|
8
|
+
help: `bp login [--write] [--no-browser]
|
|
9
|
+
|
|
10
|
+
Authenticate via your browser (OAuth 2.0 + PKCE) and store credentials locally.
|
|
11
|
+
Requests read-only access by default; --write also allows modifying your
|
|
12
|
+
collection. --no-browser just prints the URL to open manually (useful over SSH).
|
|
24
13
|
|
|
25
|
-
|
|
14
|
+
For headless / CI use, skip login and set BIBLIOPLEX_TOKEN to a personal access
|
|
15
|
+
token (created in the Biblioplex web app) instead.`,
|
|
16
|
+
async run(ctx) {
|
|
17
|
+
const scope = boolFlag(ctx.flags, 'write') ? `${READ_SCOPE} ${WRITE_SCOPE}` : READ_SCOPE;
|
|
18
|
+
const { tokens, clientId, tokenEndpoint } = await login({
|
|
19
|
+
origin: ctx.apiOrigin,
|
|
20
|
+
scope,
|
|
21
|
+
out: ctx.out,
|
|
22
|
+
noBrowser: boolFlag(ctx.flags, 'no-browser'),
|
|
23
|
+
});
|
|
24
|
+
const granted = tokens.scope || scope;
|
|
25
|
+
saveCredentials({
|
|
26
26
|
accessToken: tokens.access_token,
|
|
27
27
|
refreshToken: tokens.refresh_token,
|
|
28
|
-
|
|
29
|
-
scope:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
saveCredentials(creds);
|
|
34
|
-
|
|
35
|
-
let stats = null;
|
|
36
|
-
try {
|
|
37
|
-
const session = new Session({ base: apiBase, credentials: creds, persist: saveCredentials });
|
|
38
|
-
const { snapshot } = await loadSnapshot(session);
|
|
39
|
-
stats = summarize(collectionOf(snapshot));
|
|
40
|
-
} catch { /* confirmation is best-effort */ }
|
|
41
|
-
|
|
42
|
-
out.emit({ loggedIn: true, scope: creds.scope, collection: stats }, () => {
|
|
43
|
-
out.info(out.c.green('✓ signed in') + ' — scope: ' + creds.scope);
|
|
44
|
-
if (stats) out.info(`cloud collection: ${stats.unique} unique · ${stats.total} cards · $${stats.value}`);
|
|
28
|
+
expiresAt: Date.now() + (tokens.expires_in ? tokens.expires_in * 1000 : 3_600_000),
|
|
29
|
+
scope: granted,
|
|
30
|
+
clientId,
|
|
31
|
+
tokenEndpoint,
|
|
32
|
+
origin: ctx.apiOrigin,
|
|
45
33
|
});
|
|
46
|
-
|
|
34
|
+
ctx.out.emit({ status: 'signed_in', scope: granted, origin: ctx.apiOrigin }, () =>
|
|
35
|
+
ctx.out.line(`signed in (${granted})`),
|
|
36
|
+
);
|
|
37
|
+
return EXIT.OK;
|
|
47
38
|
},
|
|
48
39
|
};
|
package/src/commands/logout.mjs
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { clearCredentials } from '../store.mjs';
|
|
2
|
+
import { EXIT } from '../constants.mjs';
|
|
3
3
|
|
|
4
4
|
export default {
|
|
5
|
-
summary: 'sign out
|
|
6
|
-
help:
|
|
5
|
+
summary: 'sign out (clears local credentials)',
|
|
6
|
+
help: `bp logout
|
|
7
|
+
|
|
8
|
+
Remove locally stored credentials. Biblioplex has no server-side token
|
|
9
|
+
revocation, so existing tokens remain valid until they expire (access ~1h,
|
|
10
|
+
refresh ~30d). To kill a credential sooner, revoke the relevant personal access
|
|
11
|
+
token in the Biblioplex web app.`,
|
|
7
12
|
async run(ctx) {
|
|
8
|
-
const { out, apiBase } = ctx;
|
|
9
|
-
const creds = loadCredentials();
|
|
10
|
-
if (creds?.refreshToken) await revokeToken({ base: apiBase, token: creds.refreshToken });
|
|
11
|
-
if (creds?.accessToken) await revokeToken({ base: apiBase, token: creds.accessToken });
|
|
12
13
|
clearCredentials();
|
|
13
|
-
out.emit({
|
|
14
|
-
|
|
14
|
+
ctx.out.emit({ status: 'signed_out' }, () =>
|
|
15
|
+
ctx.out.line('signed out (local credentials cleared)'),
|
|
16
|
+
);
|
|
17
|
+
return EXIT.OK;
|
|
15
18
|
},
|
|
16
19
|
};
|
package/src/commands/ls.mjs
CHANGED
|
@@ -1,31 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { listContainers } from '../snapshot.mjs';
|
|
3
|
-
import { usageError } from '../errors.mjs';
|
|
4
|
-
|
|
5
|
-
// binder/box are legacy aliases for the unified container type.
|
|
6
|
-
const TYPES = { decks: 'deck', deck: 'deck', containers: 'container', container: 'container', binders: 'container', boxes: 'container', binder: 'container', box: 'container' };
|
|
1
|
+
import { emitResult, INVENTORY_COLUMNS, CONTAINER_COLUMNS } from '../render.mjs';
|
|
7
2
|
|
|
8
3
|
export default {
|
|
9
|
-
summary: 'list containers
|
|
10
|
-
help:
|
|
4
|
+
summary: 'list containers and their cards',
|
|
5
|
+
help: `bp ls [container]
|
|
6
|
+
|
|
7
|
+
With no argument, lists your containers (decks, boxes, binders). With a
|
|
8
|
+
container name, lists the cards stored in it.`,
|
|
11
9
|
async run(ctx) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
const session = await ctx.makeSession();
|
|
11
|
+
const name = ctx.args.join(' ').trim();
|
|
12
|
+
if (!name) {
|
|
13
|
+
const sc = await session.call('query_collection', { from: 'containers' });
|
|
14
|
+
return emitResult(ctx, sc, { columns: CONTAINER_COLUMNS });
|
|
17
15
|
}
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const rows = listContainers(snapshot, type);
|
|
21
|
-
|
|
22
|
-
out.emit({ containers: rows }, () => {
|
|
23
|
-
if (!rows.length) { out.info('no containers.'); return; }
|
|
24
|
-
out.table(
|
|
25
|
-
[{ header: 'type' }, { header: 'name' }, { header: 'cards', align: 'right' }, { header: 'value', align: 'right' }],
|
|
26
|
-
rows.map(r => [r.type, r.name, String(r.total), '$' + r.value.toFixed(2)]),
|
|
27
|
-
);
|
|
28
|
-
});
|
|
29
|
-
return 0;
|
|
16
|
+
const sc = await session.call('query_collection', { from: 'inventory', location: name });
|
|
17
|
+
return emitResult(ctx, sc, { columns: INVENTORY_COLUMNS });
|
|
30
18
|
},
|
|
31
19
|
};
|
package/src/commands/move.mjs
CHANGED
|
@@ -1,41 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { applyMutation } from '../mutate.mjs';
|
|
3
|
-
import { strFlag, boolFlag } from '../args.mjs';
|
|
4
|
-
import { requireWrite, selectorFrom, requireStacks, parseLocationFlag, ensureContainer, persistUndo, printWrite } from './writeHelpers.mjs';
|
|
1
|
+
import { intFlag, 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: 'move
|
|
9
|
-
help: [
|
|
10
|
-
'usage: bp move <name> --to "deck:breya" [--board sideboard] [--set --cn --finish ...] [--all] [--dry-run]',
|
|
11
|
-
'',
|
|
12
|
-
'changes a stack\'s location. --to accepts "deck:x", "container:x", or a bare name',
|
|
13
|
-
'(treated as a container). --board sets the deck board (main/sideboard/maybe).',
|
|
14
|
-
].join('\n'),
|
|
15
|
-
async run(ctx) {
|
|
16
|
-
const { out, flags, args } = ctx;
|
|
17
|
-
const session = ctx.makeSession();
|
|
18
|
-
requireWrite(session);
|
|
19
|
-
const to = parseLocationFlag(strFlag(flags, 'to'));
|
|
20
|
-
if (!to) throw usageError('move requires --to <container>');
|
|
21
|
-
const board = strFlag(flags, 'board');
|
|
22
|
-
const sel = selectorFrom(flags, args);
|
|
23
|
-
|
|
24
|
-
const result = await applyMutation(session, (draft) => {
|
|
25
|
-
const stacks = requireStacks(draft.app.collection, sel, { all: boolFlag(flags, 'all') });
|
|
26
|
-
ensureContainer(draft, to);
|
|
27
|
-
const keys = new Set(stacks.map(collectionKey));
|
|
28
|
-
for (const e of draft.app.collection) {
|
|
29
|
-
if (!keys.has(collectionKey(e))) continue;
|
|
30
|
-
e.location = { type: to.type, name: to.name };
|
|
31
|
-
if (to.type === 'deck') e.deckBoard = board || e.deckBoard || 'main';
|
|
32
|
-
else delete e.deckBoard;
|
|
33
|
-
}
|
|
34
|
-
return { moved: stacks.length, to: to.type + ':' + to.name };
|
|
35
|
-
}, { dryRun: boolFlag(flags, 'dry-run') });
|
|
6
|
+
summary: 'move cards between locations',
|
|
7
|
+
help: `bp move <itemKey> --to LOCATION [--qty N] [--yes] [--dry-run]
|
|
36
8
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
9
|
+
Move an inventory row to another location (e.g. deck:breya, box:red).
|
|
10
|
+
Get the itemKey from \`bp search ... --json\`.`,
|
|
11
|
+
async run(ctx) {
|
|
12
|
+
const itemKey = ctx.args[0] || strFlag(ctx.flags, 'item');
|
|
13
|
+
const to = strFlag(ctx.flags, 'to');
|
|
14
|
+
if (!itemKey || !to) throw usageError('usage: bp move <itemKey> --to LOCATION [--qty N]');
|
|
15
|
+
const session = await ctx.makeSession({ write: true });
|
|
16
|
+
const args = { operation: 'move', itemKey, toLocation: to };
|
|
17
|
+
const qty = intFlag(ctx.flags, 'qty');
|
|
18
|
+
if (qty != null) args.qty = qty;
|
|
19
|
+
return runMutation(ctx, session, args, { verb: 'move' });
|
|
40
20
|
},
|
|
41
21
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { strFlag } from '../args.mjs';
|
|
2
|
+
import { usageError } from '../errors.mjs';
|
|
3
|
+
import { emitResult } from '../render.mjs';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
summary: 'price snapshots for a printing',
|
|
7
|
+
help: `bp prices <scryfallId> [--finish foil|nonfoil|etched]
|
|
8
|
+
|
|
9
|
+
Per-source price snapshots for one owned printing (by Scryfall id).
|
|
10
|
+
Find ids with: bp search "..." --json`,
|
|
11
|
+
async run(ctx) {
|
|
12
|
+
const scryfallId = ctx.args[0];
|
|
13
|
+
if (!scryfallId) throw usageError('usage: bp prices <scryfallId> [--finish ...]');
|
|
14
|
+
const session = await ctx.makeSession();
|
|
15
|
+
const args = { scryfallId };
|
|
16
|
+
const finish = strFlag(ctx.flags, 'finish');
|
|
17
|
+
if (finish) args.finish = finish;
|
|
18
|
+
const sc = await session.call('card_prices', args);
|
|
19
|
+
return emitResult(ctx, sc);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { EXIT } from '../constants.mjs';
|
|
2
|
+
import { usageError } from '../errors.mjs';
|
|
3
|
+
import { emitResult } from '../render.mjs';
|
|
4
|
+
import { confirm } from '../mutate.mjs';
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
summary: 'list / preview / restore recovery points',
|
|
8
|
+
help: `bp recover list
|
|
9
|
+
bp recover preview <id>
|
|
10
|
+
bp recover restore <id> [--yes]
|
|
11
|
+
|
|
12
|
+
Recovery points are server-side snapshots taken automatically before each
|
|
13
|
+
change — your safety net if undo isn't enough.`,
|
|
14
|
+
async run(ctx) {
|
|
15
|
+
const sub = ctx.args[0] || 'list';
|
|
16
|
+
|
|
17
|
+
if (sub === 'list') {
|
|
18
|
+
const session = await ctx.makeSession();
|
|
19
|
+
const sc = await session.call('list_mcp_recovery_points', {});
|
|
20
|
+
return emitResult(ctx, sc, {
|
|
21
|
+
columns: [
|
|
22
|
+
{ key: 'id', header: 'id' },
|
|
23
|
+
{ key: 'createdAt', header: 'when' },
|
|
24
|
+
{ key: 'summary', header: 'summary' },
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const id = ctx.args[1];
|
|
30
|
+
if (!id) throw usageError(`usage: bp recover ${sub} <id>`);
|
|
31
|
+
|
|
32
|
+
if (sub === 'preview') {
|
|
33
|
+
const session = await ctx.makeSession({ write: true });
|
|
34
|
+
const sc = await session.call('restore_mcp_recovery_point', { id, dryRun: true });
|
|
35
|
+
return emitResult(ctx, sc);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (sub === 'restore') {
|
|
39
|
+
const session = await ctx.makeSession({ write: true });
|
|
40
|
+
const preview = await session.call('restore_mcp_recovery_point', { id, dryRun: true });
|
|
41
|
+
ctx.out.line('restore preview:');
|
|
42
|
+
ctx.out.line(' • ' + (preview.summary || preview.message || JSON.stringify(preview)));
|
|
43
|
+
if (!(await confirm(ctx, `restore recovery point ${id}?`))) {
|
|
44
|
+
ctx.out.info('aborted.');
|
|
45
|
+
return EXIT.OK;
|
|
46
|
+
}
|
|
47
|
+
const sc = await session.call('restore_mcp_recovery_point', { id });
|
|
48
|
+
return ctx.out.emit(sc, () => ctx.out.line('restored.')) ?? EXIT.OK;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw usageError('usage: bp recover list | preview <id> | restore <id>');
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { strFlag } from '../args.mjs';
|
|
2
|
+
import { usageError } from '../errors.mjs';
|
|
3
|
+
import { emitResult } from '../render.mjs';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
summary: 'resolve a Scryfall printing',
|
|
7
|
+
help: `bp resolve <query> [--set CODE] [--cn NUMBER] [--finish ...]
|
|
8
|
+
|
|
9
|
+
Resolve a card to concrete printings (for adding/replacing). Examples:
|
|
10
|
+
bp resolve "lightning bolt"
|
|
11
|
+
bp resolve --set sld --cn 2144`,
|
|
12
|
+
async run(ctx) {
|
|
13
|
+
const session = await ctx.makeSession();
|
|
14
|
+
const args = {};
|
|
15
|
+
const query = ctx.args.join(' ').trim();
|
|
16
|
+
if (query) args.query = query;
|
|
17
|
+
const set = strFlag(ctx.flags, 'set');
|
|
18
|
+
const cn = strFlag(ctx.flags, 'cn');
|
|
19
|
+
const finish = strFlag(ctx.flags, 'finish');
|
|
20
|
+
if (set) args.setCode = set;
|
|
21
|
+
if (cn) args.cn = cn;
|
|
22
|
+
if (finish) args.finish = finish;
|
|
23
|
+
if (!query && !(set && cn)) {
|
|
24
|
+
throw usageError('usage: bp resolve <query> (or --set CODE --cn NUMBER)');
|
|
25
|
+
}
|
|
26
|
+
const sc = await session.call('resolve_printing', args);
|
|
27
|
+
return emitResult(ctx, sc, {
|
|
28
|
+
columns: [
|
|
29
|
+
{ key: 'name', header: 'name' },
|
|
30
|
+
{ key: 'setCode', header: 'set' },
|
|
31
|
+
{ key: 'cn', header: 'cn' },
|
|
32
|
+
{ key: 'finish', header: 'finish' },
|
|
33
|
+
{ key: 'price', header: 'price', align: 'right', money: true },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
package/src/commands/rm.mjs
CHANGED
|
@@ -1,37 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { requireWrite, selectorFrom, requireStacks, persistUndo, printWrite } from './writeHelpers.mjs';
|
|
1
|
+
import { intFlag, strFlag } from '../args.mjs';
|
|
2
|
+
import { usageError } from '../errors.mjs';
|
|
3
|
+
import { runMutation } from '../mutate.mjs';
|
|
5
4
|
|
|
6
5
|
export default {
|
|
7
|
-
summary: 'remove
|
|
8
|
-
help: [
|
|
9
|
-
'usage: bp rm <name> [--set --cn --finish --condition --location] [--qty n] [--all] [--dry-run]',
|
|
10
|
-
'',
|
|
11
|
-
'removes whole matching stacks. --qty n removes only n copies. --all removes every',
|
|
12
|
-
'matching stack when a name is ambiguous.',
|
|
13
|
-
].join('\n'),
|
|
14
|
-
async run(ctx) {
|
|
15
|
-
const { out, flags, args } = ctx;
|
|
16
|
-
const session = ctx.makeSession();
|
|
17
|
-
requireWrite(session);
|
|
18
|
-
const sel = selectorFrom(flags, args);
|
|
19
|
-
const removeQty = intFlag(flags, 'qty', null);
|
|
20
|
-
|
|
21
|
-
const result = await applyMutation(session, (draft) => {
|
|
22
|
-
const stacks = requireStacks(draft.app.collection, sel, { all: boolFlag(flags, 'all') });
|
|
23
|
-
const keys = new Set(stacks.map(collectionKey));
|
|
24
|
-
if (removeQty != null) {
|
|
25
|
-
for (const e of draft.app.collection) if (keys.has(collectionKey(e))) e.qty = Math.max(0, (parseInt(e.qty, 10) || 0) - removeQty);
|
|
26
|
-
draft.app.collection = draft.app.collection.filter(e => (parseInt(e.qty, 10) || 0) > 0);
|
|
27
|
-
} else {
|
|
28
|
-
draft.app.collection = draft.app.collection.filter(e => !keys.has(collectionKey(e)));
|
|
29
|
-
}
|
|
30
|
-
return { removed: stacks.length };
|
|
31
|
-
}, { dryRun: boolFlag(flags, 'dry-run') });
|
|
6
|
+
summary: 'remove cards from your collection',
|
|
7
|
+
help: `bp rm <itemKey> [--qty N] [--yes] [--dry-run]
|
|
32
8
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
9
|
+
Delete an inventory row. Get its itemKey from \`bp search ... --json\`.
|
|
10
|
+
Without --qty, removes the whole row.`,
|
|
11
|
+
async run(ctx) {
|
|
12
|
+
const itemKey = ctx.args[0] || strFlag(ctx.flags, 'item');
|
|
13
|
+
if (!itemKey) throw usageError('usage: bp rm <itemKey> [--qty N]');
|
|
14
|
+
const session = await ctx.makeSession({ write: true });
|
|
15
|
+
const args = { operation: 'delete', itemKey };
|
|
16
|
+
const qty = intFlag(ctx.flags, 'qty');
|
|
17
|
+
if (qty != null) args.qty = qty;
|
|
18
|
+
return runMutation(ctx, session, args, { verb: 'remove' });
|
|
36
19
|
},
|
|
37
20
|
};
|
package/src/commands/search.mjs
CHANGED
|
@@ -1,42 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { cardRow, CARD_COLUMNS, cardsToCsv } from '../render.mjs';
|
|
4
|
-
import { strFlag, intFlag, boolFlag } from '../args.mjs';
|
|
1
|
+
import { intFlag, strFlag, boolFlag } from '../args.mjs';
|
|
2
|
+
import { emitResult, INVENTORY_COLUMNS } from '../render.mjs';
|
|
5
3
|
|
|
6
4
|
export default {
|
|
7
|
-
summary: 'search your
|
|
8
|
-
help: [
|
|
9
|
-
'usage: bp search <query> [--sort field] [--desc] [--limit n] [--csv|--json]',
|
|
10
|
-
'',
|
|
11
|
-
'uses the same query grammar as the web app, e.g.:',
|
|
12
|
-
' bp search "t:creature c:rg cmc<=3 -t:legendary"',
|
|
13
|
-
' bp search rare f:foil --sort price --desc',
|
|
14
|
-
'',
|
|
15
|
-
'fields: t/type c/color ci/identity cmc/mv o/oracle r/rarity s/set f/finish',
|
|
16
|
-
' loc/location tag cond/condition lang qty (prefix with - to negate)',
|
|
17
|
-
'sort: name set cn finish rarity condition location qty price cmc',
|
|
18
|
-
].join('\n'),
|
|
19
|
-
async run(ctx) {
|
|
20
|
-
const { out, flags, args } = ctx;
|
|
21
|
-
const session = ctx.makeSession();
|
|
22
|
-
const { snapshot } = await loadSnapshot(session);
|
|
23
|
-
const query = args.join(' ');
|
|
24
|
-
const sort = strFlag(flags, 'sort') || 'name';
|
|
25
|
-
const dir = boolFlag(flags, 'desc') ? 'desc' : (strFlag(flags, 'dir') || 'asc');
|
|
5
|
+
summary: 'search your inventory',
|
|
6
|
+
help: `bp search [query] [--limit N] [--sort FIELD] [--desc] [--cursor C]
|
|
26
7
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
8
|
+
Query physical inventory with Biblioplex's SQL-ish expression. Examples:
|
|
9
|
+
bp search "cmc=1 colors=blue"
|
|
10
|
+
bp search "finish=foil order by price desc" --limit 10
|
|
11
|
+
bp search 'select name,price from inventory where oracleText like "draw a card"'
|
|
30
12
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
13
|
+
Fields: name,setCode,finish,condition,qty,location,tags,typeLine,setName,cmc,
|
|
14
|
+
colors,colorIdentity,oracleText,price,totalValue.`,
|
|
15
|
+
async run(ctx) {
|
|
16
|
+
const session = await ctx.makeSession();
|
|
17
|
+
const args = { from: 'inventory' };
|
|
18
|
+
const query = ctx.args.join(' ').trim();
|
|
19
|
+
if (query) args.query = query;
|
|
20
|
+
const limit = intFlag(ctx.flags, 'limit');
|
|
21
|
+
if (limit != null) args.limit = limit;
|
|
22
|
+
const sort = strFlag(ctx.flags, 'sort');
|
|
23
|
+
if (sort) {
|
|
24
|
+
args.sortBy = sort;
|
|
25
|
+
args.sortDirection = boolFlag(ctx.flags, 'desc') ? 'desc' : 'asc';
|
|
34
26
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
});
|
|
40
|
-
return 0;
|
|
27
|
+
const cursor = strFlag(ctx.flags, 'cursor');
|
|
28
|
+
if (cursor) args.cursor = cursor;
|
|
29
|
+
const sc = await session.call('query_collection', args);
|
|
30
|
+
return emitResult(ctx, sc, { columns: INVENTORY_COLUMNS });
|
|
41
31
|
},
|
|
42
32
|
};
|
package/src/commands/summary.mjs
CHANGED
|
@@ -1,34 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { collectionOf, containersOf, summarize } from '../snapshot.mjs';
|
|
1
|
+
import { emitResult, renderTable, fmtMoney, CONTAINER_COLUMNS } from '../render.mjs';
|
|
3
2
|
|
|
4
3
|
export default {
|
|
5
|
-
summary: 'collection
|
|
6
|
-
help:
|
|
7
|
-
async run(ctx) {
|
|
8
|
-
const { out } = ctx;
|
|
9
|
-
const session = ctx.makeSession();
|
|
10
|
-
const { snapshot } = await loadSnapshot(session);
|
|
11
|
-
const collection = collectionOf(snapshot);
|
|
12
|
-
const stats = summarize(collection);
|
|
13
|
-
const containers = Object.values(containersOf(snapshot));
|
|
14
|
-
const top = collection
|
|
15
|
-
.filter(c => typeof c.price === 'number')
|
|
16
|
-
.map(c => ({ name: c.resolvedName || c.name, set: (c.setCode || '').toUpperCase(), qty: c.qty, price: c.price, value: Math.round(c.price * (c.qty || 1) * 100) / 100 }))
|
|
17
|
-
.sort((a, b) => b.value - a.value)
|
|
18
|
-
.slice(0, 10);
|
|
4
|
+
summary: 'collection overview (counts, value, containers)',
|
|
5
|
+
help: `bp summary
|
|
19
6
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
7
|
+
One-shot overview: totals, recent changes, and your containers. Use --json for
|
|
8
|
+
the full structured payload.`,
|
|
9
|
+
async run(ctx) {
|
|
10
|
+
const session = await ctx.makeSession();
|
|
11
|
+
const sc = await session.call('get_collection_overview', {});
|
|
12
|
+
return emitResult(ctx, sc, {
|
|
13
|
+
render: (out) => {
|
|
14
|
+
const s = sc.summary || sc.overview || sc;
|
|
15
|
+
const get = (...keys) => {
|
|
16
|
+
for (const k of keys) if (s?.[k] != null) return s[k];
|
|
17
|
+
return undefined;
|
|
18
|
+
};
|
|
19
|
+
const unique = get('uniqueCount', 'unique', 'uniqueCards');
|
|
20
|
+
const total = get('totalCount', 'total', 'totalCards', 'count');
|
|
21
|
+
const value = get('totalValue', 'value');
|
|
22
|
+
out.line(`unique ${unique ?? '?'} · total ${total ?? '?'} · value ${fmtMoney(value)}`);
|
|
23
|
+
if (Array.isArray(sc.containers) && sc.containers.length) {
|
|
24
|
+
out.line('');
|
|
25
|
+
out.line('containers:');
|
|
26
|
+
renderTable(out, sc.containers, CONTAINER_COLUMNS);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
31
29
|
});
|
|
32
|
-
return 0;
|
|
33
30
|
},
|
|
34
31
|
};
|
package/src/commands/undo.mjs
CHANGED
|
@@ -1,34 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { applyMutation } from '../mutate.mjs';
|
|
3
|
-
import { loadUndo, clearUndo } from '../store.mjs';
|
|
4
|
-
import { requireWrite, printWrite } from './writeHelpers.mjs';
|
|
1
|
+
import { EXIT } from '../constants.mjs';
|
|
5
2
|
import { CliError } from '../errors.mjs';
|
|
6
3
|
|
|
7
4
|
export default {
|
|
8
|
-
summary: 'undo
|
|
9
|
-
help:
|
|
10
|
-
async run(ctx) {
|
|
11
|
-
const { out } = ctx;
|
|
12
|
-
const session = ctx.makeSession();
|
|
13
|
-
requireWrite(session);
|
|
14
|
-
const record = loadUndo();
|
|
15
|
-
if (!record) throw new CliError('nothing to undo');
|
|
16
|
-
|
|
17
|
-
const result = await applyMutation(session, (draft) => {
|
|
18
|
-
for (const [key, before] of Object.entries(record.collection || {})) {
|
|
19
|
-
draft.app.collection = draft.app.collection.filter(e => collectionKey(e) !== key);
|
|
20
|
-
if (before) draft.app.collection.push(before);
|
|
21
|
-
}
|
|
22
|
-
if (Object.keys(record.containers || {}).length && !draft.app.containers) draft.app.containers = {};
|
|
23
|
-
for (const [key, before] of Object.entries(record.containers || {})) {
|
|
24
|
-
if (before) draft.app.containers[key] = before;
|
|
25
|
-
else delete draft.app.containers[key];
|
|
26
|
-
}
|
|
27
|
-
return { undone: true };
|
|
28
|
-
});
|
|
5
|
+
summary: 'undo your last collection change',
|
|
6
|
+
help: `bp undo
|
|
29
7
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
8
|
+
Undo the most recent change you made via the CLI/MCP. Only works if it is still
|
|
9
|
+
the newest change; otherwise use \`bp recover\`.`,
|
|
10
|
+
async run(ctx) {
|
|
11
|
+
const session = await ctx.makeSession({ write: true });
|
|
12
|
+
const sc = await session.call('undo_last_mcp_change', {});
|
|
13
|
+
if (sc?.status === 'not_found') throw new CliError('nothing to undo', EXIT.NOT_FOUND, sc);
|
|
14
|
+
if (sc?.status === 'unsafe') {
|
|
15
|
+
throw new CliError('not the newest change — use `bp recover` instead', EXIT.CONFLICT, sc);
|
|
16
|
+
}
|
|
17
|
+
return ctx.out.emit(sc, () => ctx.out.line('undone.')) ?? EXIT.OK;
|
|
33
18
|
},
|
|
34
19
|
};
|