biblioplex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +92 -0
  2. package/bin/biblioplex.mjs +9 -0
  3. package/package.json +42 -0
  4. package/src/api.mjs +110 -0
  5. package/src/args.mjs +77 -0
  6. package/src/cli.mjs +76 -0
  7. package/src/commands/add.mjs +57 -0
  8. package/src/commands/container.mjs +83 -0
  9. package/src/commands/deck.mjs +52 -0
  10. package/src/commands/deckExport.mjs +48 -0
  11. package/src/commands/edit.mjs +45 -0
  12. package/src/commands/export.mjs +56 -0
  13. package/src/commands/import.mjs +94 -0
  14. package/src/commands/index.mjs +40 -0
  15. package/src/commands/login.mjs +48 -0
  16. package/src/commands/logout.mjs +16 -0
  17. package/src/commands/ls.mjs +31 -0
  18. package/src/commands/move.mjs +41 -0
  19. package/src/commands/rm.mjs +37 -0
  20. package/src/commands/search.mjs +42 -0
  21. package/src/commands/show.mjs +33 -0
  22. package/src/commands/summary.mjs +34 -0
  23. package/src/commands/tag.mjs +40 -0
  24. package/src/commands/undo.mjs +34 -0
  25. package/src/commands/whoami.mjs +26 -0
  26. package/src/commands/writeHelpers.mjs +82 -0
  27. package/src/constants.mjs +25 -0
  28. package/src/errors.mjs +17 -0
  29. package/src/mutate.mjs +76 -0
  30. package/src/oauth.mjs +129 -0
  31. package/src/output.mjs +79 -0
  32. package/src/pkce.mjs +16 -0
  33. package/src/render.mjs +35 -0
  34. package/src/scryfall.mjs +81 -0
  35. package/src/snapshot.mjs +77 -0
  36. package/src/store.mjs +53 -0
  37. package/vendor/README.md +20 -0
  38. package/vendor/adapters.js +443 -0
  39. package/vendor/collection.js +665 -0
  40. package/vendor/deckExport.js +219 -0
  41. package/vendor/importMerge.js +22 -0
  42. package/vendor/importParsing.js +119 -0
  43. package/vendor/portableArchive.js +151 -0
  44. package/vendor/searchCore.js +223 -0
  45. package/vendor/state.js +91 -0
  46. package/vendor/storageSchema.js +75 -0
  47. package/vendor/syncOps.js +188 -0
@@ -0,0 +1,45 @@
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';
5
+ import { usageError } from '../errors.mjs';
6
+
7
+ 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'),
15
+ 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');
26
+ }
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;
44
+ },
45
+ };
@@ -0,0 +1,56 @@
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';
8
+
9
+ const CSV_FORMATS = ['canonical', 'moxfield', 'manabox', 'deckbox'];
10
+
11
+ 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
+ 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');
25
+
26
+ let body;
27
+ 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' };
33
+ } 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 };
44
+ }
45
+
46
+ 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 });
51
+ } else {
52
+ out.raw(body);
53
+ }
54
+ return 0;
55
+ },
56
+ };
@@ -0,0 +1,94 @@
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';
10
+ import { usageError, CliError } from '../errors.mjs';
11
+
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;
15
+
16
+ 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>');
31
+
32
+ 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;
65
+ }
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;
75
+ }
76
+
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
+ }
87
+
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` : '')),
91
+ );
92
+ return 0;
93
+ },
94
+ };
@@ -0,0 +1,40 @@
1
+ import login from './login.mjs';
2
+ import logout from './logout.mjs';
3
+ import whoami from './whoami.mjs';
4
+ import search from './search.mjs';
5
+ import summary from './summary.mjs';
6
+ import ls from './ls.mjs';
7
+ import show from './show.mjs';
8
+ import deck from './deck.mjs';
9
+ import add from './add.mjs';
10
+ import rm from './rm.mjs';
11
+ import move from './move.mjs';
12
+ import edit from './edit.mjs';
13
+ import tag from './tag.mjs';
14
+ import container from './container.mjs';
15
+ import undo from './undo.mjs';
16
+ import importCmd from './import.mjs';
17
+ import exportCmd from './export.mjs';
18
+
19
+ // Registry. commandOrder controls help-listing order.
20
+ export const commands = {
21
+ login,
22
+ logout,
23
+ whoami,
24
+ search,
25
+ summary,
26
+ ls,
27
+ show,
28
+ deck,
29
+ add,
30
+ rm,
31
+ move,
32
+ edit,
33
+ tag,
34
+ container,
35
+ undo,
36
+ import: importCmd,
37
+ export: exportCmd,
38
+ };
39
+
40
+ export const commandOrder = Object.keys(commands);
@@ -0,0 +1,48 @@
1
+ import { login as oauthLogin } from '../oauth.mjs';
2
+ import { saveCredentials } from '../store.mjs';
3
+ import { Session } from '../api.mjs';
4
+ import { READ_SCOPE, WRITE_SCOPE } from '../constants.mjs';
5
+ import { boolFlag } from '../args.mjs';
6
+ import { loadSnapshot } from '../mutate.mjs';
7
+ import { summarize, collectionOf } from '../snapshot.mjs';
8
+
9
+ export default {
10
+ summary: 'sign in to your collection',
11
+ help: [
12
+ 'usage: bp login [--write] [--no-browser]',
13
+ '',
14
+ 'opens your browser once to authorize the cli, then stores a refresh token',
15
+ 'locally so future commands stay signed in (~30 days).',
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') });
24
+
25
+ const creds = {
26
+ accessToken: tokens.access_token,
27
+ refreshToken: tokens.refresh_token,
28
+ accessExpiresAt: Date.now() + (Number(tokens.expires_in) || 3600) * 1000,
29
+ scope: tokens.scope || scope,
30
+ apiBase,
31
+ loggedInAt: new Date().toISOString(),
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}`);
45
+ });
46
+ return 0;
47
+ },
48
+ };
@@ -0,0 +1,16 @@
1
+ import { loadCredentials, clearCredentials } from '../store.mjs';
2
+ import { revokeToken } from '../oauth.mjs';
3
+
4
+ export default {
5
+ summary: 'sign out and revoke the session',
6
+ help: 'usage: bp logout\n\nrevokes the stored tokens server-side and deletes the local credentials file.',
7
+ 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
+ clearCredentials();
13
+ out.emit({ loggedOut: true }, () => out.info(out.c.green('✓ signed out')));
14
+ return 0;
15
+ },
16
+ };
@@ -0,0 +1,31 @@
1
+ import { loadSnapshot } from '../mutate.mjs';
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' };
7
+
8
+ export default {
9
+ summary: 'list containers (decks / containers)',
10
+ help: 'usage: bp ls [decks|containers]\n\nlists your decks and storage containers with card counts and value. with no argument, lists all.',
11
+ async run(ctx) {
12
+ const { out, args } = ctx;
13
+ let type = null;
14
+ if (args[0]) {
15
+ type = TYPES[args[0].toLowerCase()];
16
+ if (!type) throw usageError(`unknown container type: ${args[0]} (use decks or containers)`);
17
+ }
18
+ const session = ctx.makeSession();
19
+ const { snapshot } = await loadSnapshot(session);
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;
30
+ },
31
+ };
@@ -0,0 +1,41 @@
1
+ import { collectionKey } from '../../vendor/collection.js';
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';
5
+ import { usageError } from '../errors.mjs';
6
+
7
+ export default {
8
+ summary: 'move a card to a different container',
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') });
36
+
37
+ persistUndo(result);
38
+ printWrite(out, result, () => out.info(out.c.green('✓ moved') + ` ${result.meta.moved} stack(s) → ${result.meta.to}`));
39
+ return 0;
40
+ },
41
+ };
@@ -0,0 +1,37 @@
1
+ import { collectionKey } from '../../vendor/collection.js';
2
+ import { applyMutation } from '../mutate.mjs';
3
+ import { boolFlag, intFlag } from '../args.mjs';
4
+ import { requireWrite, selectorFrom, requireStacks, persistUndo, printWrite } from './writeHelpers.mjs';
5
+
6
+ export default {
7
+ summary: 'remove a card (stack) from your collection',
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') });
32
+
33
+ persistUndo(result);
34
+ printWrite(out, result, () => out.info(out.c.green('✓ removed') + ` ${result.meta.removed} stack(s)`));
35
+ return 0;
36
+ },
37
+ };
@@ -0,0 +1,42 @@
1
+ import { loadSnapshot } from '../mutate.mjs';
2
+ import { runQuery, collectionOf } from '../snapshot.mjs';
3
+ import { cardRow, CARD_COLUMNS, cardsToCsv } from '../render.mjs';
4
+ import { strFlag, intFlag, boolFlag } from '../args.mjs';
5
+
6
+ export default {
7
+ summary: 'search your collection (app query syntax)',
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');
26
+
27
+ let list = runQuery(collectionOf(snapshot), query, { sort, dir });
28
+ const limit = intFlag(flags, 'limit', null);
29
+ if (limit != null) list = list.slice(0, limit);
30
+
31
+ if (boolFlag(flags, 'csv')) {
32
+ out.raw(cardsToCsv(list, strFlag(flags, 'format') || 'canonical'));
33
+ return 0;
34
+ }
35
+ out.emit({ count: list.length, cards: list }, () => {
36
+ if (!list.length) { out.info('no cards match.'); return; }
37
+ out.table(CARD_COLUMNS, list.map(cardRow));
38
+ out.info(out.c.dim(`${list.length} stack${list.length === 1 ? '' : 's'}`));
39
+ });
40
+ return 0;
41
+ },
42
+ };
@@ -0,0 +1,33 @@
1
+ import { loadSnapshot } from '../mutate.mjs';
2
+ import { findContainer, containerCards } from '../snapshot.mjs';
3
+ import { runQuery } from '../snapshot.mjs';
4
+ import { cardRow, CARD_COLUMNS, cardsToCsv } from '../render.mjs';
5
+ import { strFlag, boolFlag } from '../args.mjs';
6
+ import { usageError, CliError } from '../errors.mjs';
7
+
8
+ export default {
9
+ summary: 'show the cards in a container',
10
+ help: 'usage: bp show <container> [--sort field] [--csv|--json]\n\ncontainer may be "deck:breya", "binder:rares", or just "breya" if unambiguous.',
11
+ async run(ctx) {
12
+ const { out, args, flags } = ctx;
13
+ const ref = args.join(' ');
14
+ if (!ref) throw usageError('usage: bp show <container>');
15
+ const session = ctx.makeSession();
16
+ const { snapshot } = await loadSnapshot(session);
17
+ const { matches, name } = findContainer(snapshot, ref);
18
+ if (!matches.length) throw new CliError(`no container named "${name}"`);
19
+ if (matches.length > 1) {
20
+ throw new CliError(`"${name}" is ambiguous — try ${matches.map(m => m.type + ':' + m.name).join(', ')}`);
21
+ }
22
+ const container = matches[0];
23
+ const cards = runQuery(containerCards(snapshot, container), '', { sort: strFlag(flags, 'sort') || 'name', dir: boolFlag(flags, 'desc') ? 'desc' : 'asc' });
24
+
25
+ if (boolFlag(flags, 'csv')) { out.raw(cardsToCsv(cards, strFlag(flags, 'format') || 'canonical')); return 0; }
26
+ out.emit({ container: { type: container.type, name: container.name }, count: cards.length, cards }, () => {
27
+ out.line(out.c.bold(container.type + ':' + container.name));
28
+ if (!cards.length) { out.info('(empty)'); return; }
29
+ out.table(CARD_COLUMNS, cards.map(cardRow));
30
+ });
31
+ return 0;
32
+ },
33
+ };
@@ -0,0 +1,34 @@
1
+ import { loadSnapshot } from '../mutate.mjs';
2
+ import { collectionOf, containersOf, summarize } from '../snapshot.mjs';
3
+
4
+ export default {
5
+ summary: 'collection totals and top-value cards',
6
+ help: 'usage: bp summary [--json]\n\nshows unique stacks, total cards, estimated value, container count, and your most valuable cards.',
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);
19
+
20
+ out.emit({ ...stats, containers: containers.length, topValue: top }, () => {
21
+ out.line(out.c.bold('collection summary'));
22
+ out.line(` unique stacks : ${stats.unique}`);
23
+ out.line(` total cards : ${stats.total}`);
24
+ out.line(` est. value : $${stats.value.toFixed(2)}`);
25
+ out.line(` containers : ${containers.length}`);
26
+ if (top.length) {
27
+ out.line('');
28
+ out.line(out.c.bold('top value'));
29
+ for (const c of top) out.line(` $${c.value.toFixed(2).padStart(8)} ${c.name} (${c.set})${c.qty > 1 ? ' x' + c.qty : ''}`);
30
+ }
31
+ });
32
+ return 0;
33
+ },
34
+ };
@@ -0,0 +1,40 @@
1
+ import { collectionKey, normalizeTag } from '../../vendor/collection.js';
2
+ import { applyMutation } from '../mutate.mjs';
3
+ import { boolFlag } from '../args.mjs';
4
+ import { requireWrite, selectorFrom, requireStacks, persistUndo, printWrite } from './writeHelpers.mjs';
5
+ import { usageError } from '../errors.mjs';
6
+
7
+ export default {
8
+ summary: 'add or remove a tag on a stack',
9
+ help: [
10
+ 'usage: bp tag add <tag> <name> [--set --cn --finish ...] [--all]',
11
+ ' bp tag rm <tag> <name> [...]',
12
+ '',
13
+ 'adds/removes a single tag on matching stacks.',
14
+ ].join('\n'),
15
+ async run(ctx) {
16
+ const { out, flags, args } = ctx;
17
+ const session = ctx.makeSession();
18
+ requireWrite(session);
19
+ const [op, rawTag, ...nameParts] = args;
20
+ if ((op !== 'add' && op !== 'rm') || !rawTag) throw usageError('usage: bp tag <add|rm> <tag> <name>');
21
+ const tag = normalizeTag(rawTag);
22
+ const sel = selectorFrom(flags, nameParts);
23
+
24
+ const result = await applyMutation(session, (draft) => {
25
+ const stacks = requireStacks(draft.app.collection, sel, { all: boolFlag(flags, 'all') });
26
+ const keys = new Set(stacks.map(collectionKey));
27
+ for (const e of draft.app.collection) {
28
+ if (!keys.has(collectionKey(e))) continue;
29
+ const tags = new Set((e.tags || []).map(normalizeTag).filter(Boolean));
30
+ if (op === 'add') tags.add(tag); else tags.delete(tag);
31
+ e.tags = [...tags];
32
+ }
33
+ return { tagged: stacks.length, op, tag };
34
+ }, { dryRun: boolFlag(flags, 'dry-run') });
35
+
36
+ persistUndo(result);
37
+ printWrite(out, result, () => out.info(out.c.green('✓ ' + (op === 'add' ? 'tagged' : 'untagged')) + ` ${result.meta.tagged} stack(s) (${tag})`));
38
+ return 0;
39
+ },
40
+ };
@@ -0,0 +1,34 @@
1
+ import { collectionKey } from '../../vendor/collection.js';
2
+ import { applyMutation } from '../mutate.mjs';
3
+ import { loadUndo, clearUndo } from '../store.mjs';
4
+ import { requireWrite, printWrite } from './writeHelpers.mjs';
5
+ import { CliError } from '../errors.mjs';
6
+
7
+ export default {
8
+ summary: 'undo the last change made by this cli',
9
+ help: 'usage: bp undo\n\nrestores exactly the cards and containers your last cli write touched.',
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
+ });
29
+
30
+ clearUndo();
31
+ printWrite(out, result, () => out.info(out.c.green('✓ undone')));
32
+ return 0;
33
+ },
34
+ };
@@ -0,0 +1,26 @@
1
+ import { loadCredentials } from '../store.mjs';
2
+ import { authError } from '../errors.mjs';
3
+
4
+ export default {
5
+ summary: 'show the current session',
6
+ help: 'usage: bp whoami\n\nshows the api endpoint, granted scopes, and when you signed in.',
7
+ async run(ctx) {
8
+ const { out, apiBase } = ctx;
9
+ const creds = loadCredentials();
10
+ if (!creds?.accessToken) throw authError();
11
+ const data = {
12
+ apiBase,
13
+ scope: creds.scope,
14
+ canWrite: (creds.scope || '').split(/\s+/).includes('collection.write'),
15
+ loggedInAt: creds.loggedInAt || null,
16
+ accessExpiresAt: creds.accessExpiresAt ? new Date(creds.accessExpiresAt).toISOString() : null,
17
+ };
18
+ out.emit(data, () => {
19
+ out.line('api: ' + apiBase);
20
+ out.line('scope: ' + creds.scope);
21
+ out.line('access: ' + (data.canWrite ? 'read + write' : 'read only'));
22
+ if (creds.loggedInAt) out.line('since: ' + creds.loggedInAt);
23
+ });
24
+ return 0;
25
+ },
26
+ };