biblioplex 0.1.0 → 0.2.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 (57) hide show
  1. package/package.json +23 -27
  2. package/src/__tests__/args.test.js +39 -0
  3. package/src/__tests__/csv.test.js +33 -0
  4. package/src/__tests__/mcp.test.js +84 -0
  5. package/src/__tests__/mutate.test.js +91 -0
  6. package/src/__tests__/render.test.js +103 -0
  7. package/src/args.mjs +29 -6
  8. package/src/cli.mjs +38 -25
  9. package/src/commands/add.mjs +30 -51
  10. package/src/commands/deck.mjs +13 -47
  11. package/src/commands/edit.mjs +26 -38
  12. package/src/commands/export.mjs +57 -41
  13. package/src/commands/history.mjs +24 -0
  14. package/src/commands/import.mjs +52 -80
  15. package/src/commands/index.mjs +44 -20
  16. package/src/commands/login.mjs +29 -38
  17. package/src/commands/logout.mjs +13 -10
  18. package/src/commands/ls.mjs +13 -25
  19. package/src/commands/move.mjs +15 -35
  20. package/src/commands/prices.mjs +21 -0
  21. package/src/commands/recover.mjs +53 -0
  22. package/src/commands/resolve.mjs +37 -0
  23. package/src/commands/rm.mjs +15 -32
  24. package/src/commands/search.mjs +25 -35
  25. package/src/commands/summary.mjs +25 -28
  26. package/src/commands/undo.mjs +13 -28
  27. package/src/commands/value.mjs +30 -0
  28. package/src/commands/whoami.mjs +27 -19
  29. package/src/constants.mjs +34 -7
  30. package/src/csv.mjs +71 -0
  31. package/src/errors.mjs +13 -2
  32. package/src/mcp.mjs +227 -0
  33. package/src/mutate.mjs +142 -67
  34. package/src/oauth.mjs +200 -62
  35. package/src/output.mjs +27 -18
  36. package/src/render.mjs +135 -30
  37. package/src/store.mjs +39 -23
  38. package/README.md +0 -92
  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/README.md DELETED
@@ -1,92 +0,0 @@
1
- # biblioplex cli
2
-
3
- manage your [biblioplex](https://biblioplex.bensonperry.com) magic: the gathering
4
- collection from the terminal — search, edit, import, and export, all against your
5
- live cloud collection.
6
-
7
- cloud-first: every command reads and writes the same collection the web app uses,
8
- so the cli and the website stay perfectly in sync.
9
-
10
- ## install
11
-
12
- requires **node 20+**.
13
-
14
- ```sh
15
- npm install -g biblioplex # then run `biblioplex` or `bp`
16
- # or, no install:
17
- npx biblioplex login
18
- ```
19
-
20
- > the global command is `biblioplex`. it also installs a short `bp` alias; if some
21
- > other tool already owns `bp` on your machine, just use `biblioplex` (or add your
22
- > own alias).
23
-
24
- ## sign in
25
-
26
- ```sh
27
- bp login # read-only (search/export)
28
- bp login --write # also allow edits/imports
29
- ```
30
-
31
- `bp login` opens your browser once to authorize the cli, then stores a refresh
32
- token locally so you stay signed in for ~30 days. `bp logout` revokes it.
33
-
34
- on a headless box: `bp login --no-browser` prints a url to open elsewhere.
35
-
36
- ## examples
37
-
38
- ```sh
39
- # search with the same query grammar as the web app
40
- bp search "t:creature c:rg cmc<=3 -t:legendary"
41
- bp search rare f:foil --sort price --desc --limit 20
42
-
43
- # totals and your most valuable cards
44
- bp summary
45
-
46
- # containers
47
- bp ls decks
48
- bp show "deck:breya"
49
- bp deck show breya
50
- bp deck export breya --preset moxfield > breya.txt
51
-
52
- # edits (need `bp login --write`)
53
- bp add "Sol Ring" --set c21 --cn 263 --qty 2 --location "box:bulk"
54
- bp edit "Sol Ring" --condition lightly_played
55
- bp move "Sol Ring" --to "deck:breya"
56
- bp tag add trade "Sol Ring"
57
- bp rm "Sol Ring"
58
- bp undo # revert the last cli change
59
-
60
- # bulk import / export (cloud stays the source of truth)
61
- bp import moxfield-export.csv # auto-detects the format
62
- bp export "f:foil" --format moxfield > foils.txt
63
- bp export --archive --output backup.json
64
- ```
65
-
66
- ambiguous card names list the matching stacks; narrow with
67
- `--set/--cn/--finish/--condition/--location`, or apply to all with `--all`.
68
-
69
- ## scripting / agents
70
-
71
- every command accepts `--json` and prints a stable envelope to stdout:
72
-
73
- ```json
74
- { "ok": true, "data": { ... } }
75
- { "ok": false, "error": { "message": "..." } }
76
- ```
77
-
78
- diagnostics go to stderr, so `--json` stdout is always a single json document.
79
- exit codes: `0` ok · `1` error · `2` usage · `3` auth (run `bp login`) · `4` rate-limited.
80
-
81
- ## configuration
82
-
83
- - credentials: `~/.config/biblioplex/credentials.json` (mode 600)
84
- - override the api endpoint: `--api <url>` or `BIBLIOPLEX_API_BASE`
85
- - override the config dir: `BIBLIOPLEX_CONFIG_DIR`
86
-
87
- ## notes
88
-
89
- - zero runtime dependencies (node built-ins only).
90
- - card search/sort, csv adapters, and the collection model are shared verbatim
91
- with the web app (see `vendor/`), so behavior matches exactly.
92
- - macOS and linux are supported in this release.
package/src/api.mjs DELETED
@@ -1,110 +0,0 @@
1
- // Authenticated transport to the biblioplex worker.
2
- // - reads: GET /sync/bootstrap (full snapshot)
3
- // - writes: POST /sync/push (granular ops built locally from a diff)
4
- // - tools: POST /mcp JSON-RPC (search_card_printings, preview_*/apply, undo)
5
- // Refreshes the access token proactively (near expiry) and reactively (on 401),
6
- // persisting rotated tokens. The token is sent only in the Authorization header.
7
- import { refreshTokens } from './oauth.mjs';
8
- import { CliError, authError, rateLimitError } from './errors.mjs';
9
- import { CLIENT_LABEL } from './constants.mjs';
10
-
11
- export class Session {
12
- constructor({ base, credentials, persist, fetchImpl = fetch }) {
13
- this.base = base;
14
- this.creds = credentials;
15
- this.persist = persist || (() => {});
16
- this.fetchImpl = fetchImpl;
17
- this._rpcId = 0;
18
- }
19
-
20
- get scopes() { return (this.creds?.scope || '').split(/\s+/).filter(Boolean); }
21
- hasScope(scope) { return this.scopes.includes(scope); }
22
-
23
- async _refresh() {
24
- if (!this.creds?.refreshToken) throw authError('session expired — run `bp login`');
25
- this._absorb(await refreshTokens({ base: this.base, refreshToken: this.creds.refreshToken, fetchImpl: this.fetchImpl }));
26
- }
27
-
28
- _absorb(tokens) {
29
- this.creds = {
30
- ...this.creds,
31
- accessToken: tokens.access_token,
32
- refreshToken: tokens.refresh_token || this.creds.refreshToken,
33
- accessExpiresAt: Date.now() + (Number(tokens.expires_in) || 3600) * 1000,
34
- scope: tokens.scope || this.creds.scope,
35
- };
36
- this.persist(this.creds);
37
- }
38
-
39
- async _authedFetch(path, opts = {}, retried = false) {
40
- if (!this.creds?.accessToken) throw authError();
41
- if (!retried && this.creds.accessExpiresAt && this.creds.accessExpiresAt - Date.now() < 30000) {
42
- await this._refresh();
43
- }
44
- const headers = { ...(opts.headers || {}), Authorization: 'Bearer ' + this.creds.accessToken };
45
- const res = await this.fetchImpl(this.base + path, { ...opts, headers });
46
- if (res.status === 401 && !retried) { await this._refresh(); return this._authedFetch(path, opts, true); }
47
- if (res.status === 401) throw authError('session expired — run `bp login`');
48
- if (res.status === 403) {
49
- const body = await res.json().catch(() => ({}));
50
- const msg = body.error === 'insufficient_scope'
51
- ? 'this session lacks write access — run `bp login --write`'
52
- : (body.error || 'forbidden');
53
- throw new CliError(msg, 1, body);
54
- }
55
- if (res.status === 429) throw rateLimitError();
56
- return res;
57
- }
58
-
59
- async bootstrap() {
60
- const res = await this._authedFetch('/sync/bootstrap');
61
- if (!res.ok) throw new CliError('could not load collection (' + res.status + ')');
62
- return res.json();
63
- }
64
-
65
- async push({ ops, baseRevision }) {
66
- const res = await this._authedFetch('/sync/push', {
67
- method: 'POST',
68
- headers: { 'Content-Type': 'application/json' },
69
- body: JSON.stringify({ clientId: CLIENT_LABEL, baseRevision, requireBaseRevision: true, ops }),
70
- });
71
- if (res.status === 409) {
72
- const data = await res.json().catch(() => ({}));
73
- const err = new CliError('the cloud collection changed since this command started', 1, data);
74
- err.conflict = true;
75
- throw err;
76
- }
77
- if (!res.ok) {
78
- const data = await res.json().catch(() => ({}));
79
- throw new CliError('push failed: ' + (data.error || res.status), 1, data);
80
- }
81
- return res.json();
82
- }
83
-
84
- async mcp(method, params = {}) {
85
- const id = ++this._rpcId;
86
- const res = await this._authedFetch('/mcp', {
87
- method: 'POST',
88
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
89
- body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
90
- });
91
- if (!res.ok) throw new CliError('mcp request failed (' + res.status + ')');
92
- const data = await res.json().catch(() => ({}));
93
- if (data.error) {
94
- if (data.error.code === -32003 || /insufficient_scope/.test(data.error.message || '')) {
95
- throw new CliError('this session lacks write access — run `bp login --write`', 1, data.error);
96
- }
97
- throw new CliError(data.error.message || 'mcp error', 1, data.error);
98
- }
99
- return data.result;
100
- }
101
-
102
- async callTool(name, args = {}) {
103
- const result = await this.mcp('tools/call', { name, arguments: args });
104
- if (result?.isError) {
105
- const text = (result.content || []).map(c => c.text).filter(Boolean).join(' ');
106
- throw new CliError(text || ('tool ' + name + ' failed'), 1, result);
107
- }
108
- return result?.structuredContent ?? result ?? null;
109
- }
110
- }
@@ -1,83 +0,0 @@
1
- import { locationKey } from '../../vendor/collection.js';
2
- import { applyMutation } from '../mutate.mjs';
3
- import { parseContainerRef } from '../snapshot.mjs';
4
- import { boolFlag } from '../args.mjs';
5
- import { requireWrite, parseLocationFlag, ensureContainer, persistUndo, printWrite } from './writeHelpers.mjs';
6
- import { usageError, CliError } from '../errors.mjs';
7
-
8
- function resolveContainer(draft, ref) {
9
- const containers = draft.app.containers || {};
10
- const loc = parseLocationFlag(ref);
11
- if (loc && containers[loc.type + ':' + loc.name]) return { key: loc.type + ':' + loc.name, container: containers[loc.type + ':' + loc.name] };
12
- const name = parseContainerRef(ref).name;
13
- const matches = Object.entries(containers).filter(([, c]) => c.name === name);
14
- if (!matches.length) throw new CliError(`no container "${ref}"`);
15
- if (matches.length > 1) throw new CliError(`"${ref}" is ambiguous — use type:name (${matches.map(([k]) => k).join(', ')})`);
16
- return { key: matches[0][0], container: matches[0][1] };
17
- }
18
-
19
- export default {
20
- summary: 'create / rename / delete a container',
21
- help: [
22
- 'usage: bp container create <type:name> (type = deck|container)',
23
- ' bp container rename <type:name> <new-name>',
24
- ' bp container delete <type:name> [--force] (--force unfiles its cards)',
25
- '',
26
- 'legacy "binder:"/"box:" prefixes are accepted and treated as containers.',
27
- ].join('\n'),
28
- async run(ctx) {
29
- const { out, flags, args } = ctx;
30
- const session = ctx.makeSession();
31
- requireWrite(session);
32
- const [sub, ref, ...rest] = args;
33
-
34
- if (sub === 'create') {
35
- const loc = parseLocationFlag(ref);
36
- if (!loc) throw usageError('usage: bp container create <type:name>');
37
- const result = await applyMutation(session, (draft) => { ensureContainer(draft, loc); return { created: loc.type + ':' + loc.name }; }, { dryRun: boolFlag(flags, 'dry-run') });
38
- persistUndo(result);
39
- printWrite(out, result, () => out.info(out.c.green('✓ created') + ` ${loc.type}:${loc.name}`));
40
- return 0;
41
- }
42
-
43
- if (sub === 'rename') {
44
- const newName = parseContainerRef(rest.join(' ')).name;
45
- if (!ref || !newName) throw usageError('usage: bp container rename <type:name> <new-name>');
46
- const result = await applyMutation(session, (draft) => {
47
- const { key, container } = resolveContainer(draft, ref);
48
- const newKey = container.type + ':' + newName;
49
- if (draft.app.containers[newKey]) throw new CliError(`a ${container.type} named "${newName}" already exists`);
50
- const renamed = { ...container, name: newName };
51
- // Keep the deck's display title in sync if it tracked the old name.
52
- if (renamed.deck && (!renamed.deck.title || renamed.deck.title === container.name)) {
53
- renamed.deck = { ...renamed.deck, title: newName };
54
- }
55
- draft.app.containers[newKey] = renamed;
56
- delete draft.app.containers[key];
57
- for (const e of draft.app.collection) if (locationKey(e.location) === key) e.location = { type: container.type, name: newName };
58
- return { renamed: key, to: newKey };
59
- }, { dryRun: boolFlag(flags, 'dry-run') });
60
- persistUndo(result);
61
- printWrite(out, result, () => out.info(out.c.green('✓ renamed') + ` ${result.meta.renamed} → ${result.meta.to}`));
62
- return 0;
63
- }
64
-
65
- if (sub === 'delete') {
66
- const result = await applyMutation(session, (draft) => {
67
- const { key, container } = resolveContainer(draft, ref);
68
- const cards = draft.app.collection.filter(e => locationKey(e.location) === key);
69
- if (cards.length && !boolFlag(flags, 'force')) {
70
- throw new CliError(`${container.type}:${container.name} holds ${cards.length} stack(s) — pass --force to delete and unfile them`);
71
- }
72
- for (const e of draft.app.collection) if (locationKey(e.location) === key) { e.location = null; delete e.deckBoard; }
73
- delete draft.app.containers[key];
74
- return { deleted: key, unfiled: cards.length };
75
- }, { dryRun: boolFlag(flags, 'dry-run') });
76
- persistUndo(result);
77
- printWrite(out, result, () => out.info(out.c.green('✓ deleted') + ` ${result.meta.deleted}` + (result.meta.unfiled ? ` (unfiled ${result.meta.unfiled})` : '')));
78
- return 0;
79
- }
80
-
81
- throw usageError('usage: bp container <create|rename|delete> <type:name>');
82
- },
83
- };
@@ -1,48 +0,0 @@
1
- import { writeFileSync } from 'node:fs';
2
- import { loadSnapshot } from '../mutate.mjs';
3
- import { findContainer, containerCards } from '../snapshot.mjs';
4
- import { buildDeckExport } from '../../vendor/deckExport.js';
5
- import { strFlag } from '../args.mjs';
6
- import { usageError, CliError } from '../errors.mjs';
7
-
8
- // `bp deck export <name> [--preset ...] [--boards a,b] [--output file]`
9
- export async function runDeckExport(ctx, args) {
10
- const { out, flags } = ctx;
11
- const name = args.join(' ');
12
- if (!name) throw usageError('usage: bp deck export <name> [--preset plain|moxfield|arena|mtgo|csv|json]');
13
-
14
- const session = ctx.makeSession();
15
- const { snapshot } = await loadSnapshot(session);
16
- const { matches } = findContainer(snapshot, 'deck:' + name);
17
- const decks = matches.filter(m => m.type === 'deck');
18
- if (!decks.length) throw new CliError(`no deck named "${name}"`);
19
-
20
- const deck = decks[0];
21
- const cards = containerCards(snapshot, deck);
22
- const preset = strFlag(flags, 'preset') || 'plain';
23
- const boardsFlag = strFlag(flags, 'boards');
24
- const boards = boardsFlag ? boardsFlag.split(',').map(s => s.trim()).filter(Boolean) : undefined;
25
- const dm = deck.deck || {};
26
- // buildDeckExport expects commander/partner as plain name strings; the app
27
- // stores them as { name, scryfallId, ... } objects.
28
- const meta = {
29
- title: deck.name,
30
- format: dm.format,
31
- commander: dm.commander?.name || (typeof dm.commander === 'string' ? dm.commander : undefined),
32
- partner: dm.partner?.name || (typeof dm.partner === 'string' ? dm.partner : undefined),
33
- };
34
-
35
- const result = buildDeckExport(cards, meta, { preset, ...(boards ? { boards } : {}) });
36
- for (const w of result.warnings || []) out.info(out.c.yellow('! ' + w));
37
-
38
- const file = strFlag(flags, 'output', 'o');
39
- if (file) {
40
- writeFileSync(file, result.body.endsWith('\n') ? result.body : result.body + '\n');
41
- out.emit({ file, filename: result.filename, preset }, () => out.info(`wrote ${file}`));
42
- } else if (out.json) {
43
- out.emit({ preset, filename: result.filename, body: result.body, warnings: result.warnings });
44
- } else {
45
- out.raw(result.body);
46
- }
47
- return 0;
48
- }
@@ -1,33 +0,0 @@
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
- };
@@ -1,40 +0,0 @@
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
- };
@@ -1,82 +0,0 @@
1
- import { normalizeLocation, locationKey, makeContainer } from '../../vendor/collection.js';
2
- import { saveUndo } from '../store.mjs';
3
- import { CliError } from '../errors.mjs';
4
- import { strFlag, intFlag } from '../args.mjs';
5
-
6
- export function requireWrite(session) {
7
- if (!session.hasScope('collection.write')) {
8
- throw new CliError('this session is read-only — run `bp login --write`', 3);
9
- }
10
- }
11
-
12
- export function parseLocationFlag(value) {
13
- return value ? normalizeLocation(value) : null;
14
- }
15
-
16
- // Build a stack selector from the card name (positional) + qualifier flags.
17
- export function selectorFrom(flags, nameParts) {
18
- const name = (nameParts || []).join(' ').trim();
19
- return {
20
- name: name || null,
21
- scryfallId: strFlag(flags, 'scryfall-id', 'id'),
22
- set: strFlag(flags, 'set', 's'),
23
- cn: strFlag(flags, 'cn'),
24
- finish: strFlag(flags, 'finish'),
25
- condition: strFlag(flags, 'condition', 'cond'),
26
- location: parseLocationFlag(strFlag(flags, 'location', 'loc')),
27
- };
28
- }
29
-
30
- export function matchStack(c, sel) {
31
- if (sel.scryfallId && c.scryfallId !== sel.scryfallId) return false;
32
- if (sel.name && !(String(c.resolvedName || c.name || '').toLowerCase().includes(sel.name.toLowerCase()))) return false;
33
- if (sel.set && String(c.setCode || '').toLowerCase() !== sel.set.toLowerCase()) return false;
34
- if (sel.cn && String(c.cn) !== String(sel.cn)) return false;
35
- if (sel.finish && c.finish !== sel.finish) return false;
36
- if (sel.condition && c.condition !== sel.condition) return false;
37
- if (sel.location && locationKey(c.location) !== locationKey(sel.location)) return false;
38
- return true;
39
- }
40
-
41
- export function findStacks(collection, sel) {
42
- return (collection || []).filter(c => matchStack(c, sel));
43
- }
44
-
45
- // Resolve a selector to exactly one stack unless `all`, with a helpful
46
- // disambiguation error listing the candidates.
47
- export function requireStacks(collection, sel, { all = false } = {}) {
48
- if (!sel.name && !sel.scryfallId && !sel.set) throw new CliError('specify a card (name, or --set/--cn, or --scryfall-id)');
49
- const stacks = findStacks(collection, sel);
50
- if (!stacks.length) throw new CliError('no matching card in your collection');
51
- if (stacks.length > 1 && !all) {
52
- const lines = stacks.slice(0, 10).map(c =>
53
- ` ${c.resolvedName || c.name} · ${(c.setCode || '').toUpperCase()} ${c.cn} · ${c.finish} · ${c.condition} · ${locationKey(c.location) || '—'}`);
54
- throw new CliError(`${stacks.length} stacks match — narrow with --set/--cn/--finish/--condition/--location, or pass --all:\n${lines.join('\n')}`);
55
- }
56
- return stacks;
57
- }
58
-
59
- export function ensureContainer(draft, location) {
60
- if (!location) return;
61
- const key = location.type + ':' + location.name;
62
- if (!draft.app.containers) draft.app.containers = {};
63
- if (!draft.app.containers[key]) draft.app.containers[key] = makeContainer({ type: location.type, name: location.name });
64
- }
65
-
66
- export function persistUndo(result) {
67
- if (result?.undo && !result.dryRun && !result.noop) saveUndo(result.undo);
68
- }
69
-
70
- export function printWrite(out, result, humanFn) {
71
- if (result.noop) { out.emit({ changed: false }, () => out.info('no change.')); return; }
72
- if (result.dryRun) {
73
- out.emit(
74
- { dryRun: true, ops: result.ops.length, opTypes: result.ops.map(o => o.type) },
75
- () => out.info(out.c.dim(`dry run — ${result.ops.length} op(s): ${result.ops.map(o => o.type).join(', ')}`)),
76
- );
77
- return;
78
- }
79
- out.emit({ changed: true, revision: result.revision, ops: result.ops.length, ...(result.meta || {}) }, humanFn);
80
- }
81
-
82
- export { intFlag };
package/src/scryfall.mjs DELETED
@@ -1,81 +0,0 @@
1
- // Direct Scryfall lookups for resolving printings on `add` and `import`.
2
- // Direct (not via the server's MCP tool) so bulk imports aren't throttled by the
3
- // worker's 60/60s MCP limit; we apply our own polite delay instead. Scryfall is
4
- // already the product's card-data source.
5
- import { VERSION } from './constants.mjs';
6
- import { getUsdPrice } from '../vendor/collection.js';
7
- import { CliError } from './errors.mjs';
8
-
9
- const SCRYFALL = 'https://api.scryfall.com';
10
- const USER_AGENT = `biblioplex-cli/${VERSION} (+https://biblioplex.bensonperry.com)`;
11
-
12
- export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
13
-
14
- async function sfGet(path, fetchImpl) {
15
- const res = await fetchImpl(SCRYFALL + path, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' } });
16
- if (res.status === 404) return null;
17
- const data = await res.json().catch(() => ({}));
18
- if (res.status === 429) throw new CliError('scryfall rate limit — slow down and retry', 4);
19
- if (!res.ok) throw new CliError('scryfall error: ' + (data.details || res.status));
20
- return data;
21
- }
22
-
23
- export function getById(id, fetchImpl = fetch) {
24
- return sfGet('/cards/' + encodeURIComponent(id), fetchImpl);
25
- }
26
-
27
- export function getBySetCn(set, cn, fetchImpl = fetch) {
28
- return sfGet(`/cards/${encodeURIComponent(String(set).toLowerCase())}/${encodeURIComponent(cn)}`, fetchImpl);
29
- }
30
-
31
- export function getByName(name, { set, fetchImpl = fetch } = {}) {
32
- const q = new URLSearchParams({ fuzzy: name });
33
- if (set) q.set('set', String(set).toLowerCase());
34
- return sfGet('/cards/named?' + q.toString(), fetchImpl);
35
- }
36
-
37
- // Resolve the most specific identifier available to a Scryfall card object.
38
- export async function resolvePrinting({ scryfallId, set, cn, name, fetchImpl = fetch }) {
39
- let card = null;
40
- if (scryfallId) card = await getById(scryfallId, fetchImpl);
41
- else if (set && cn) card = await getBySetCn(set, cn, fetchImpl);
42
- else if (name) card = await getByName(name, { set, fetchImpl });
43
- if (!card || card.object === 'error') return null;
44
- return card;
45
- }
46
-
47
- // Map a Scryfall card to the resolved-field shape normalizeCollectionEntry
48
- // expects (handles double-faced cards for colors/image/oracle).
49
- export function cardToFields(card, finish = 'normal') {
50
- const faces = Array.isArray(card.card_faces) ? card.card_faces : [];
51
- const front = faces[0] || {};
52
- const back = faces[1];
53
- const colors = card.colors ?? (faces.length ? [...new Set(faces.flatMap(f => f.colors || []))] : []);
54
- // Match the web app's resolution: join faces with " // " and use getUsdPrice
55
- // (which falls back to the non-foil price for foil/etched when the exact-finish
56
- // price is missing, setting priceFallback).
57
- const oracleText = card.oracle_text ?? (faces.length ? faces.map(f => f.oracle_text || '').join(' // ') : '');
58
- const typeLine = card.type_line || (faces.length ? faces.map(f => f.type_line || '').filter(Boolean).join(' // ') : '') || '';
59
- const { price, fallback } = getUsdPrice(card, finish);
60
- return {
61
- scryfallId: card.id,
62
- setCode: card.set,
63
- setName: card.set_name,
64
- cn: card.collector_number,
65
- name: card.name,
66
- rarity: card.rarity,
67
- cmc: card.cmc,
68
- colors,
69
- colorIdentity: card.color_identity || [],
70
- typeLine,
71
- oracleText,
72
- legalities: card.legalities || {},
73
- finishes: card.finishes || [],
74
- imageUrl: card.image_uris?.normal || front.image_uris?.normal || null,
75
- backImageUrl: back?.image_uris?.normal || null,
76
- resolvedName: card.name,
77
- scryfallUri: card.scryfall_uri || null,
78
- price,
79
- priceFallback: fallback,
80
- };
81
- }
package/src/snapshot.mjs DELETED
@@ -1,77 +0,0 @@
1
- // Read-side helpers over a bootstrap snapshot, reusing the app's search core.
2
- import { tokenizeSearch, matchSearch, passesMultiselectFilters, compareCards } from '../vendor/searchCore.js';
3
- import { locationKey } from '../vendor/collection.js';
4
-
5
- export function emptySnapshot() {
6
- return {
7
- app: { schemaVersion: 1, collection: [], containers: {}, ui: { selectedFormat: '' } },
8
- history: [],
9
- shares: [],
10
- };
11
- }
12
-
13
- export function collectionOf(snapshot) {
14
- return snapshot?.app?.collection || [];
15
- }
16
-
17
- export function containersOf(snapshot) {
18
- return snapshot?.app?.containers || {};
19
- }
20
-
21
- // Filter + sort a collection with the exact app grammar and sort order.
22
- export function runQuery(collection, query = '', { sort = 'name', dir = 'asc', filters = {} } = {}) {
23
- const tokens = tokenizeSearch(query || '');
24
- const filtered = (collection || []).filter(c => matchSearch(c, tokens) && passesMultiselectFilters(c, filters));
25
- const sign = dir === 'desc' ? -1 : 1;
26
- return [...filtered].sort((a, b) => sign * compareCards(a, b, sort));
27
- }
28
-
29
- // Parse a container reference like "deck:breya", "breya", or "binder:rares".
30
- // Returns { type, name } where type may be null if unqualified.
31
- export function parseContainerRef(ref) {
32
- const m = String(ref || '').trim().match(/^(deck|container|binder|box)\s*[:]\s*(.+)$/i);
33
- if (m) {
34
- const t = m[1].toLowerCase();
35
- return { type: t === 'deck' ? 'deck' : 'container', name: m[2].trim().toLowerCase() };
36
- }
37
- return { type: null, name: String(ref || '').trim().toLowerCase() };
38
- }
39
-
40
- export function listContainers(snapshot, type = null) {
41
- const containers = Object.values(containersOf(snapshot));
42
- const collection = collectionOf(snapshot);
43
- return containers
44
- .filter(c => !type || c.type === type)
45
- .map(c => {
46
- const key = c.type + ':' + c.name;
47
- const cards = collection.filter(card => locationKey(card.location) === key);
48
- const stats = summarize(cards);
49
- return { type: c.type, name: c.name, unique: stats.unique, total: stats.total, value: stats.value, meta: c.deck || null };
50
- })
51
- .sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
52
- }
53
-
54
- export function findContainer(snapshot, ref) {
55
- const { type, name } = parseContainerRef(ref);
56
- const containers = Object.values(containersOf(snapshot));
57
- const matches = containers.filter(c => c.name === name && (!type || c.type === type));
58
- return { matches, type, name };
59
- }
60
-
61
- export function containerCards(snapshot, container) {
62
- const key = container.type + ':' + container.name;
63
- return collectionOf(snapshot).filter(card => locationKey(card.location) === key);
64
- }
65
-
66
- export function summarize(collection) {
67
- let unique = 0;
68
- let total = 0;
69
- let value = 0;
70
- for (const c of collection || []) {
71
- unique += 1;
72
- const qty = parseInt(c.qty, 10) || 0;
73
- total += qty;
74
- if (typeof c.price === 'number') value += c.price * qty;
75
- }
76
- return { unique, total, value: Math.round(value * 100) / 100 };
77
- }
package/vendor/README.md DELETED
@@ -1,20 +0,0 @@
1
- # vendor/
2
-
3
- Generated by `scripts/sync-vendor.mjs` — do not edit by hand.
4
-
5
- These files are byte-identical copies of the biblioplex web app modules the
6
- CLI reuses (search grammar, CSV adapters, collection model, sync op diffing,
7
- deck export). The web app is the source of truth; re-run `npm run sync-vendor`
8
- after changing any of them.
9
-
10
- Vendored modules:
11
- - adapters.js
12
- - collection.js
13
- - deckExport.js
14
- - importMerge.js
15
- - importParsing.js
16
- - portableArchive.js
17
- - searchCore.js
18
- - state.js
19
- - storageSchema.js
20
- - syncOps.js