biblioplex 0.1.1 → 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.
- package/package.json +23 -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/README.md +0 -92
- 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/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
|
};
|
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
|
};
|