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/vendor/searchCore.js
DELETED
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
// Pure, DOM-free search / match / sort core.
|
|
2
|
-
// Imported by the web app (search.js) and the biblioplex CLI so both share one
|
|
3
|
-
// implementation of the query grammar and sort order. Depends only on
|
|
4
|
-
// collection.js (which depends only on state.js) — no document/window/localStorage.
|
|
5
|
-
import { normalizeLocation, locationKey, formatLocationLabel } from './collection.js';
|
|
6
|
-
|
|
7
|
-
const SEARCH_FIELD_ALIASES = {
|
|
8
|
-
n: 'name', name: 'name',
|
|
9
|
-
t: 'type', type: 'type',
|
|
10
|
-
c: 'colors', color: 'colors', colors: 'colors',
|
|
11
|
-
ci: 'ci', identity: 'ci',
|
|
12
|
-
cmc: 'cmc', mv: 'cmc',
|
|
13
|
-
o: 'oracle', oracle: 'oracle', text: 'oracle',
|
|
14
|
-
r: 'rarity', rarity: 'rarity',
|
|
15
|
-
loc: 'loc', location: 'loc',
|
|
16
|
-
tag: 'tag', tags: 'tag',
|
|
17
|
-
s: 'set', set: 'set',
|
|
18
|
-
f: 'finish', finish: 'finish',
|
|
19
|
-
qty: 'qty',
|
|
20
|
-
cond: 'cond', condition: 'cond',
|
|
21
|
-
lang: 'lang', language: 'lang',
|
|
22
|
-
};
|
|
23
|
-
const RARITY_SHORT = { c: 'common', u: 'uncommon', r: 'rare', m: 'mythic' };
|
|
24
|
-
|
|
25
|
-
export function tokenizeSearch(query) {
|
|
26
|
-
const tokens = [];
|
|
27
|
-
let i = 0;
|
|
28
|
-
const s = query;
|
|
29
|
-
const isSpace = ch => /\s/.test(ch);
|
|
30
|
-
while (i < s.length) {
|
|
31
|
-
while (i < s.length && isSpace(s[i])) i++;
|
|
32
|
-
if (i >= s.length) break;
|
|
33
|
-
let neg = false;
|
|
34
|
-
if (s[i] === '-' && i + 1 < s.length && !isSpace(s[i + 1])) { neg = true; i++; }
|
|
35
|
-
const fieldStart = i;
|
|
36
|
-
while (i < s.length && /[a-zA-Z]/.test(s[i])) i++;
|
|
37
|
-
const fieldRaw = s.slice(fieldStart, i).toLowerCase();
|
|
38
|
-
let op = null;
|
|
39
|
-
if (i < s.length && (s[i] === ':' || s[i] === '<' || s[i] === '>' || s[i] === '=')) {
|
|
40
|
-
if (s[i] === '<' || s[i] === '>') {
|
|
41
|
-
op = s[i++];
|
|
42
|
-
if (s[i] === '=') op += s[i++];
|
|
43
|
-
} else {
|
|
44
|
-
op = s[i++];
|
|
45
|
-
}
|
|
46
|
-
} else {
|
|
47
|
-
i = fieldStart;
|
|
48
|
-
}
|
|
49
|
-
let value = '';
|
|
50
|
-
if (i < s.length && s[i] === '"') {
|
|
51
|
-
i++;
|
|
52
|
-
while (i < s.length && s[i] !== '"') value += s[i++];
|
|
53
|
-
if (i < s.length && s[i] === '"') i++;
|
|
54
|
-
} else {
|
|
55
|
-
while (i < s.length && !isSpace(s[i])) value += s[i++];
|
|
56
|
-
}
|
|
57
|
-
if (op && SEARCH_FIELD_ALIASES[fieldRaw]) {
|
|
58
|
-
tokens.push({ field: SEARCH_FIELD_ALIASES[fieldRaw], op, value, neg });
|
|
59
|
-
} else if (value) {
|
|
60
|
-
tokens.push({ field: 'name', op: ':', value, neg });
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return tokens;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function compareNum(a, op, b) {
|
|
67
|
-
switch (op) {
|
|
68
|
-
case ':': case '=': return a === b;
|
|
69
|
-
case '<': return a < b;
|
|
70
|
-
case '<=': return a <= b;
|
|
71
|
-
case '>': return a > b;
|
|
72
|
-
case '>=': return a >= b;
|
|
73
|
-
}
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function colorSetFromValue(value) {
|
|
78
|
-
const lower = value.toLowerCase();
|
|
79
|
-
if (lower === 'colorless' || lower === 'c') return new Set();
|
|
80
|
-
return new Set(lower.replace(/[^wubrg]/g, '').split(''));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function matchToken(c, token) {
|
|
84
|
-
let result = false;
|
|
85
|
-
const v = token.value;
|
|
86
|
-
switch (token.field) {
|
|
87
|
-
case 'name': {
|
|
88
|
-
const name = (c.resolvedName || c.name || '').toLowerCase();
|
|
89
|
-
result = name.includes(v.toLowerCase());
|
|
90
|
-
break;
|
|
91
|
-
}
|
|
92
|
-
case 'type': {
|
|
93
|
-
result = (c.typeLine || '').toLowerCase().includes(v.toLowerCase());
|
|
94
|
-
break;
|
|
95
|
-
}
|
|
96
|
-
case 'colors':
|
|
97
|
-
case 'ci': {
|
|
98
|
-
const arr = (token.field === 'ci' ? c.colorIdentity : c.colors) || [];
|
|
99
|
-
const cardSet = new Set(arr.map(x => x.toLowerCase()));
|
|
100
|
-
const wanted = colorSetFromValue(v);
|
|
101
|
-
if (wanted.size === 0) {
|
|
102
|
-
result = cardSet.size === 0;
|
|
103
|
-
} else {
|
|
104
|
-
result = [...wanted].every(w => cardSet.has(w));
|
|
105
|
-
}
|
|
106
|
-
break;
|
|
107
|
-
}
|
|
108
|
-
case 'cmc': {
|
|
109
|
-
const cmc = c.cmc;
|
|
110
|
-
if (cmc == null) { result = false; break; }
|
|
111
|
-
const target = parseFloat(v);
|
|
112
|
-
if (isNaN(target)) { result = false; break; }
|
|
113
|
-
result = compareNum(cmc, token.op, target);
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
case 'oracle': {
|
|
117
|
-
result = (c.oracleText || '').toLowerCase().includes(v.toLowerCase());
|
|
118
|
-
break;
|
|
119
|
-
}
|
|
120
|
-
case 'rarity': {
|
|
121
|
-
const r = (c.rarity || '').toLowerCase();
|
|
122
|
-
const want = v.toLowerCase();
|
|
123
|
-
result = r === want || r === RARITY_SHORT[want];
|
|
124
|
-
break;
|
|
125
|
-
}
|
|
126
|
-
case 'loc': {
|
|
127
|
-
const loc = normalizeLocation(c.location);
|
|
128
|
-
if (!loc) { result = false; break; }
|
|
129
|
-
const want = v.toLowerCase().replace(/^(binder|box):/, 'container:');
|
|
130
|
-
// Match against the joined "type:name" label so substrings of either
|
|
131
|
-
// field, AND the full typed label like "container:rares", all match.
|
|
132
|
-
const label = loc.type + ':' + loc.name;
|
|
133
|
-
result = label.includes(want);
|
|
134
|
-
break;
|
|
135
|
-
}
|
|
136
|
-
case 'tag': {
|
|
137
|
-
const cardTags = c.tags || [];
|
|
138
|
-
const want = v.toLowerCase();
|
|
139
|
-
result = cardTags.some(t => t.toLowerCase().includes(want));
|
|
140
|
-
break;
|
|
141
|
-
}
|
|
142
|
-
case 'set': {
|
|
143
|
-
result = (c.setCode || '').toLowerCase() === v.toLowerCase();
|
|
144
|
-
break;
|
|
145
|
-
}
|
|
146
|
-
case 'finish': {
|
|
147
|
-
result = (c.finish || '').toLowerCase() === v.toLowerCase();
|
|
148
|
-
break;
|
|
149
|
-
}
|
|
150
|
-
case 'qty': {
|
|
151
|
-
const target = parseFloat(v);
|
|
152
|
-
if (isNaN(target)) { result = false; break; }
|
|
153
|
-
result = compareNum(c.qty || 0, token.op, target);
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
156
|
-
case 'cond': {
|
|
157
|
-
const cn = (c.condition || '').toLowerCase().replace(/_/g, ' ');
|
|
158
|
-
result = cn.includes(v.toLowerCase().replace(/_/g, ' '));
|
|
159
|
-
break;
|
|
160
|
-
}
|
|
161
|
-
case 'lang': {
|
|
162
|
-
result = (c.language || '').toLowerCase() === v.toLowerCase();
|
|
163
|
-
break;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return token.neg ? !result : result;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function matchSearch(c, tokens) {
|
|
170
|
-
if (tokens.length === 0) return true;
|
|
171
|
-
return tokens.every(t => matchToken(c, t));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Pure helper: applies multiselect filters to a card. Exported for tests.
|
|
175
|
-
// Each `*Selected` argument is an array of selected values (empty = no filter).
|
|
176
|
-
// `format`, when truthy, is a Scryfall format key (e.g. 'modern', 'commander').
|
|
177
|
-
// Cards explicitly marked banned/not_legal in that format are excluded.
|
|
178
|
-
// Cards with unknown legality (legacy entries pre-backfill, or in-flight
|
|
179
|
-
// scryfall lookups) pass — lenient default avoids hiding cards just because
|
|
180
|
-
// the metadata hasn't loaded yet.
|
|
181
|
-
export function passesMultiselectFilters(c, { sets, rarities, finishes, locations, tags, format } = {}) {
|
|
182
|
-
if (sets && sets.length && !sets.includes(c.setCode)) return false;
|
|
183
|
-
if (rarities && rarities.length && !rarities.includes(c.rarity)) return false;
|
|
184
|
-
if (finishes && finishes.length && !finishes.includes(c.finish)) return false;
|
|
185
|
-
if (locations && locations.length && !locations.includes(locationKey(c.location))) return false;
|
|
186
|
-
if (tags && tags.length) {
|
|
187
|
-
const cardTags = c.tags || [];
|
|
188
|
-
if (!cardTags.some(t => tags.includes(t))) return false;
|
|
189
|
-
}
|
|
190
|
-
if (format && c.legalities && typeof c.legalities === 'object') {
|
|
191
|
-
const status = c.legalities[format];
|
|
192
|
-
if (status === 'banned' || status === 'not_legal') return false;
|
|
193
|
-
}
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export const RARITY_ORDER = { common: 0, uncommon: 1, rare: 2, mythic: 3, special: 4, bonus: 5 };
|
|
198
|
-
export const CONDITION_ORDER = { near_mint: 0, lightly_played: 1, moderately_played: 2, heavily_played: 3, damaged: 4 };
|
|
199
|
-
|
|
200
|
-
export function compareCards(a, b, field) {
|
|
201
|
-
const an = (a.resolvedName || a.name || '').toLowerCase();
|
|
202
|
-
const bn = (b.resolvedName || b.name || '').toLowerCase();
|
|
203
|
-
const fallback = an.localeCompare(bn);
|
|
204
|
-
switch (field) {
|
|
205
|
-
case 'name': return an.localeCompare(bn);
|
|
206
|
-
case 'set': return (a.setCode || '').localeCompare(b.setCode || '') || fallback;
|
|
207
|
-
case 'cn': {
|
|
208
|
-
const ai = parseInt(a.cn || '', 10);
|
|
209
|
-
const bi = parseInt(b.cn || '', 10);
|
|
210
|
-
const aValid = !isNaN(ai), bValid = !isNaN(bi);
|
|
211
|
-
if (aValid && bValid && ai !== bi) return ai - bi;
|
|
212
|
-
return (a.cn || '').localeCompare(b.cn || '') || fallback;
|
|
213
|
-
}
|
|
214
|
-
case 'finish': return (a.finish || '').localeCompare(b.finish || '') || fallback;
|
|
215
|
-
case 'rarity': return ((RARITY_ORDER[a.rarity] ?? 99) - (RARITY_ORDER[b.rarity] ?? 99)) || fallback;
|
|
216
|
-
case 'condition': return ((CONDITION_ORDER[a.condition] ?? 99) - (CONDITION_ORDER[b.condition] ?? 99)) || fallback;
|
|
217
|
-
case 'location': return formatLocationLabel(a.location).localeCompare(formatLocationLabel(b.location)) || fallback;
|
|
218
|
-
case 'qty': return (a.qty || 0) - (b.qty || 0) || fallback;
|
|
219
|
-
case 'price': return (a.price || 0) - (b.price || 0) || fallback;
|
|
220
|
-
case 'cmc': return (a.cmc ?? 999) - (b.cmc ?? 999) || fallback;
|
|
221
|
-
default: return fallback;
|
|
222
|
-
}
|
|
223
|
-
}
|
package/vendor/state.js
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
function isPlainObject(value) {
|
|
2
|
-
return value && typeof value === 'object' && !Array.isArray(value);
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
function cloneSelectedKeys(raw) {
|
|
6
|
-
if (raw instanceof Set) return new Set(raw);
|
|
7
|
-
if (Array.isArray(raw)) return new Set(raw);
|
|
8
|
-
return new Set();
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function createInitialState(overrides = {}) {
|
|
12
|
-
const initial = {
|
|
13
|
-
collection: [],
|
|
14
|
-
containers: {},
|
|
15
|
-
// Top-level routes: 'collection' | 'decks' | 'storage'.
|
|
16
|
-
viewMode: 'collection',
|
|
17
|
-
// The single physical container currently being browsed, independent from
|
|
18
|
-
// broad collection filters. Stored as { type, name } or null.
|
|
19
|
-
activeLocation: null,
|
|
20
|
-
// Binder-shape escape hatch only. Collection/decks/storage don't use this.
|
|
21
|
-
viewAsList: false,
|
|
22
|
-
collectionDisplayMode: 'table',
|
|
23
|
-
selectedFormat: '',
|
|
24
|
-
selectedKeys: new Set(),
|
|
25
|
-
detailIndex: -1,
|
|
26
|
-
deckGroupBy: 'type',
|
|
27
|
-
deckMode: 'visual',
|
|
28
|
-
deckBoardFilter: 'all',
|
|
29
|
-
deckCardSize: 'medium',
|
|
30
|
-
deckShowPrices: true,
|
|
31
|
-
deckOwnershipView: 'building',
|
|
32
|
-
deckSampleHand: null,
|
|
33
|
-
binderSize: '4x3',
|
|
34
|
-
binderPage: 0,
|
|
35
|
-
binderShowPrices: true,
|
|
36
|
-
binderMode: 'view',
|
|
37
|
-
binderSort: 'binder',
|
|
38
|
-
binderSearch: '',
|
|
39
|
-
binderColorFilter: '',
|
|
40
|
-
binderTypeFilter: '',
|
|
41
|
-
sortField: null,
|
|
42
|
-
sortDir: 'asc',
|
|
43
|
-
// When non-null, the app is in read-only viewer mode for someone else's
|
|
44
|
-
// shared deck. Set by share.js initShareViewer(); cleared by reloading
|
|
45
|
-
// without `?share=`. Persisted writes are guarded in persistence.js.
|
|
46
|
-
shareSnapshot: null,
|
|
47
|
-
};
|
|
48
|
-
const next = { ...initial, ...overrides };
|
|
49
|
-
next.collection = Array.isArray(overrides.collection) ? overrides.collection : initial.collection;
|
|
50
|
-
next.containers = isPlainObject(overrides.containers) ? overrides.containers : initial.containers;
|
|
51
|
-
next.selectedKeys = cloneSelectedKeys(overrides.selectedKeys);
|
|
52
|
-
return next;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Shared mutable state. Use object properties (not let bindings) so
|
|
56
|
-
// other modules can both read and reassign through `state.x = ...`.
|
|
57
|
-
export const state = createInitialState();
|
|
58
|
-
|
|
59
|
-
export function resetState(overrides = {}) {
|
|
60
|
-
const next = createInitialState(overrides);
|
|
61
|
-
for (const key of Object.keys(state)) delete state[key];
|
|
62
|
-
Object.assign(state, next);
|
|
63
|
-
return state;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function applyLoadedState(loaded = {}) {
|
|
67
|
-
const defaults = createInitialState();
|
|
68
|
-
state.collection = Array.isArray(loaded.collection) ? loaded.collection : defaults.collection;
|
|
69
|
-
state.containers = isPlainObject(loaded.containers) ? loaded.containers : defaults.containers;
|
|
70
|
-
state.viewMode = typeof loaded.viewMode === 'string' ? loaded.viewMode : defaults.viewMode;
|
|
71
|
-
state.activeLocation = loaded.activeLocation ?? defaults.activeLocation;
|
|
72
|
-
state.viewAsList = Boolean(loaded.viewAsList);
|
|
73
|
-
state.collectionDisplayMode = loaded.collectionDisplayMode === 'visual' ? 'visual' : defaults.collectionDisplayMode;
|
|
74
|
-
state.selectedFormat = typeof loaded.selectedFormat === 'string' ? loaded.selectedFormat : defaults.selectedFormat;
|
|
75
|
-
state.sortField = typeof loaded.sortField === 'string' && loaded.sortField ? loaded.sortField : defaults.sortField;
|
|
76
|
-
state.sortDir = loaded.sortDir === 'desc' ? 'desc' : defaults.sortDir;
|
|
77
|
-
state.selectedKeys = new Set();
|
|
78
|
-
state.detailIndex = defaults.detailIndex;
|
|
79
|
-
state.deckSampleHand = defaults.deckSampleHand;
|
|
80
|
-
state.binderPage = defaults.binderPage;
|
|
81
|
-
state.shareSnapshot = loaded.shareSnapshot ?? defaults.shareSnapshot;
|
|
82
|
-
return state;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export const STORAGE_KEY = 'mtgcollection_v1';
|
|
86
|
-
export const DECK_GROUP_KEY = 'mtgcollection_deck_group_v1';
|
|
87
|
-
export const DECK_VIEW_PREFS_KEY = 'mtgcollection_deck_view_prefs_v1';
|
|
88
|
-
export const BINDER_SIZE_KEY = 'mtgcollection_binder_size_v1';
|
|
89
|
-
export const BINDER_PRICES_KEY = 'mtgcollection_binder_prices_v1';
|
|
90
|
-
export const BINDER_VIEW_PREFS_KEY = 'mtgcollection_binder_view_prefs_v1';
|
|
91
|
-
export const SCRYFALL_API = 'https://api.scryfall.com';
|
package/vendor/storageSchema.js
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
normalizeCollectionEntry,
|
|
3
|
-
normalizeContainers,
|
|
4
|
-
} from './collection.js';
|
|
5
|
-
|
|
6
|
-
export const APP_STORAGE_SCHEMA_VERSION = 1;
|
|
7
|
-
const VALID_STORED_VIEW_MODES = ['collection', 'decks', 'storage'];
|
|
8
|
-
const VALID_COLLECTION_DISPLAY_MODES = ['table', 'visual'];
|
|
9
|
-
|
|
10
|
-
function isPlainObject(value) {
|
|
11
|
-
return value && typeof value === 'object' && !Array.isArray(value);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function normalizeStoredViewMode(raw) {
|
|
15
|
-
if (VALID_STORED_VIEW_MODES.includes(raw)) return raw;
|
|
16
|
-
if (raw === 'locations') return 'storage';
|
|
17
|
-
return 'collection';
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function normalizeStoredCollectionDisplayMode(raw) {
|
|
21
|
-
return VALID_COLLECTION_DISPLAY_MODES.includes(raw) ? raw : 'table';
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function normalizeStoredUi(raw = {}) {
|
|
25
|
-
const source = isPlainObject(raw) ? raw : {};
|
|
26
|
-
return {
|
|
27
|
-
viewMode: normalizeStoredViewMode(source.viewMode),
|
|
28
|
-
viewAsList: Boolean(source.viewAsList),
|
|
29
|
-
collectionDisplayMode: normalizeStoredCollectionDisplayMode(source.collectionDisplayMode),
|
|
30
|
-
selectedFormat: typeof source.selectedFormat === 'string' ? source.selectedFormat : '',
|
|
31
|
-
sortField: typeof source.sortField === 'string' && source.sortField ? source.sortField : null,
|
|
32
|
-
sortDir: source.sortDir === 'desc' ? 'desc' : 'asc',
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function normalizeStoredCollectionEntry(raw) {
|
|
37
|
-
if (!isPlainObject(raw)) return null;
|
|
38
|
-
return normalizeCollectionEntry(raw, { preserveResolvedFields: true });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function normalizeStoredCollection(rawCollection) {
|
|
42
|
-
if (!Array.isArray(rawCollection)) return null;
|
|
43
|
-
return rawCollection.map(normalizeStoredCollectionEntry).filter(Boolean);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function normalizeStoredAppData(raw) {
|
|
47
|
-
if (!isPlainObject(raw)) return null;
|
|
48
|
-
const rawVersion = raw.schemaVersion;
|
|
49
|
-
if (
|
|
50
|
-
rawVersion != null
|
|
51
|
-
&& rawVersion !== APP_STORAGE_SCHEMA_VERSION
|
|
52
|
-
&& rawVersion !== String(APP_STORAGE_SCHEMA_VERSION)
|
|
53
|
-
) {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const collection = normalizeStoredCollection(raw.collection);
|
|
58
|
-
if (!collection) return null;
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
schemaVersion: APP_STORAGE_SCHEMA_VERSION,
|
|
62
|
-
collection,
|
|
63
|
-
containers: normalizeContainers(raw.containers),
|
|
64
|
-
ui: normalizeStoredUi(isPlainObject(raw.ui) ? raw.ui : raw),
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function serializeAppState(stateRef) {
|
|
69
|
-
return {
|
|
70
|
-
schemaVersion: APP_STORAGE_SCHEMA_VERSION,
|
|
71
|
-
collection: Array.isArray(stateRef.collection) ? stateRef.collection : [],
|
|
72
|
-
containers: isPlainObject(stateRef.containers) ? stateRef.containers : {},
|
|
73
|
-
ui: normalizeStoredUi(stateRef),
|
|
74
|
-
};
|
|
75
|
-
}
|
package/vendor/syncOps.js
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import { collectionKey, containerKey, normalizeContainers } from './collection.js';
|
|
2
|
-
import { makeSyncSnapshot } from './portableArchive.js';
|
|
3
|
-
|
|
4
|
-
export const SYNC_OP_SCHEMA_VERSION = 1;
|
|
5
|
-
const SYNCED_UI_KEYS = new Set(['selectedFormat']);
|
|
6
|
-
|
|
7
|
-
function cloneJson(value, fallback = null) {
|
|
8
|
-
if (value == null) return fallback;
|
|
9
|
-
try {
|
|
10
|
-
return JSON.parse(JSON.stringify(value));
|
|
11
|
-
} catch (e) {
|
|
12
|
-
return fallback;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function stableStringify(value) {
|
|
17
|
-
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
|
|
18
|
-
if (value && typeof value === 'object') {
|
|
19
|
-
return '{' + Object.keys(value).sort().map(key => (
|
|
20
|
-
JSON.stringify(key) + ':' + stableStringify(value[key])
|
|
21
|
-
)).join(',') + '}';
|
|
22
|
-
}
|
|
23
|
-
return JSON.stringify(value);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function sameJson(a, b) {
|
|
27
|
-
return stableStringify(a) === stableStringify(b);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function entrySoftIdentity(entry) {
|
|
31
|
-
if (!entry) return '';
|
|
32
|
-
return [
|
|
33
|
-
entry.scryfallId || '',
|
|
34
|
-
entry.setCode || '',
|
|
35
|
-
entry.cn || '',
|
|
36
|
-
entry.name || entry.resolvedName || '',
|
|
37
|
-
entry.finish || '',
|
|
38
|
-
entry.condition || '',
|
|
39
|
-
entry.language || '',
|
|
40
|
-
].join('|');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function opId(prefix) {
|
|
44
|
-
return prefix + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function makeSyncOp(type, payload = {}, options = {}) {
|
|
48
|
-
return {
|
|
49
|
-
schemaVersion: SYNC_OP_SCHEMA_VERSION,
|
|
50
|
-
id: options.id || opId('op'),
|
|
51
|
-
type,
|
|
52
|
-
ts: options.ts || Date.now(),
|
|
53
|
-
payload: cloneJson(payload, {}),
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function collectionMap(collection = []) {
|
|
58
|
-
const out = new Map();
|
|
59
|
-
for (const entry of collection || []) out.set(collectionKey(entry), entry);
|
|
60
|
-
return out;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function containersMap(containers = {}) {
|
|
64
|
-
const out = new Map();
|
|
65
|
-
for (const container of Object.values(normalizeContainers(containers))) {
|
|
66
|
-
out.set(containerKey(container), container);
|
|
67
|
-
}
|
|
68
|
-
return out;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function diffCollection(beforeCollection = [], afterCollection = []) {
|
|
72
|
-
const ops = [];
|
|
73
|
-
const before = collectionMap(beforeCollection);
|
|
74
|
-
const after = collectionMap(afterCollection);
|
|
75
|
-
const removed = new Map();
|
|
76
|
-
const added = new Map();
|
|
77
|
-
|
|
78
|
-
for (const [key, entry] of before.entries()) {
|
|
79
|
-
if (!after.has(key)) removed.set(key, entry);
|
|
80
|
-
}
|
|
81
|
-
for (const [key, entry] of after.entries()) {
|
|
82
|
-
if (!before.has(key)) added.set(key, entry);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
for (const [key, beforeEntry] of before.entries()) {
|
|
86
|
-
if (!after.has(key)) continue;
|
|
87
|
-
const afterEntry = after.get(key);
|
|
88
|
-
if (sameJson(beforeEntry, afterEntry)) continue;
|
|
89
|
-
const beforeSansQty = { ...beforeEntry, qty: afterEntry.qty };
|
|
90
|
-
if (sameJson(beforeSansQty, afterEntry)) {
|
|
91
|
-
ops.push(makeSyncOp('collection.qtyDelta', {
|
|
92
|
-
key,
|
|
93
|
-
delta: (parseInt(afterEntry.qty, 10) || 0) - (parseInt(beforeEntry.qty, 10) || 0),
|
|
94
|
-
entry: afterEntry,
|
|
95
|
-
}));
|
|
96
|
-
} else {
|
|
97
|
-
ops.push(makeSyncOp('collection.replace', {
|
|
98
|
-
beforeKey: key,
|
|
99
|
-
afterKey: key,
|
|
100
|
-
entry: afterEntry,
|
|
101
|
-
}));
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const matchedRemoved = new Set();
|
|
106
|
-
const matchedAdded = new Set();
|
|
107
|
-
for (const [addedKey, addedEntry] of added.entries()) {
|
|
108
|
-
const addedIdentity = entrySoftIdentity(addedEntry);
|
|
109
|
-
if (!addedIdentity) continue;
|
|
110
|
-
for (const [removedKey, removedEntry] of removed.entries()) {
|
|
111
|
-
if (matchedRemoved.has(removedKey)) continue;
|
|
112
|
-
if (entrySoftIdentity(removedEntry) !== addedIdentity) continue;
|
|
113
|
-
matchedRemoved.add(removedKey);
|
|
114
|
-
matchedAdded.add(addedKey);
|
|
115
|
-
ops.push(makeSyncOp('collection.replace', {
|
|
116
|
-
beforeKey: removedKey,
|
|
117
|
-
afterKey: addedKey,
|
|
118
|
-
entry: addedEntry,
|
|
119
|
-
}));
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
for (const [key, entry] of added.entries()) {
|
|
125
|
-
if (matchedAdded.has(key)) continue;
|
|
126
|
-
ops.push(makeSyncOp('collection.upsert', { key, entry }));
|
|
127
|
-
}
|
|
128
|
-
for (const [key] of removed.entries()) {
|
|
129
|
-
if (matchedRemoved.has(key)) continue;
|
|
130
|
-
ops.push(makeSyncOp('collection.remove', { key }));
|
|
131
|
-
}
|
|
132
|
-
return ops;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function diffContainers(beforeContainers = {}, afterContainers = {}) {
|
|
136
|
-
const ops = [];
|
|
137
|
-
const before = containersMap(beforeContainers);
|
|
138
|
-
const after = containersMap(afterContainers);
|
|
139
|
-
for (const [key, container] of after.entries()) {
|
|
140
|
-
if (!before.has(key) || !sameJson(before.get(key), container)) {
|
|
141
|
-
ops.push(makeSyncOp('container.upsert', { key, container }));
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
for (const [key] of before.entries()) {
|
|
145
|
-
if (!after.has(key)) ops.push(makeSyncOp('container.remove', { key }));
|
|
146
|
-
}
|
|
147
|
-
return ops;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function diffUi(beforeUi = {}, afterUi = {}) {
|
|
151
|
-
const patch = {};
|
|
152
|
-
for (const key of Object.keys(afterUi || {})) {
|
|
153
|
-
if (!SYNCED_UI_KEYS.has(key)) continue;
|
|
154
|
-
if (!sameJson(beforeUi?.[key], afterUi[key])) patch[key] = cloneJson(afterUi[key], afterUi[key]);
|
|
155
|
-
}
|
|
156
|
-
return Object.keys(patch).length ? [makeSyncOp('ui.patch', { patch })] : [];
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function diffHistory(beforeHistory = [], afterHistory = []) {
|
|
160
|
-
if (sameJson(beforeHistory, afterHistory)) return [];
|
|
161
|
-
const beforeIds = new Set(beforeHistory.map(ev => ev?.id).filter(Boolean));
|
|
162
|
-
const appended = afterHistory.filter(ev => ev?.id && !beforeIds.has(ev.id));
|
|
163
|
-
if (appended.length && beforeHistory.length + appended.length === afterHistory.length) {
|
|
164
|
-
return appended.map(event => makeSyncOp('history.append', { event }));
|
|
165
|
-
}
|
|
166
|
-
return [makeSyncOp('history.replace', { history: afterHistory })];
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function diffSyncSnapshots(beforeRaw, afterRaw) {
|
|
170
|
-
const before = makeSyncSnapshot(beforeRaw);
|
|
171
|
-
const after = makeSyncSnapshot(afterRaw);
|
|
172
|
-
if (!after) return [];
|
|
173
|
-
if (!before) return [makeSyncOp('snapshot.replace', { snapshot: after })];
|
|
174
|
-
|
|
175
|
-
const ops = [
|
|
176
|
-
...diffCollection(before.app.collection, after.app.collection),
|
|
177
|
-
...diffContainers(before.app.containers, after.app.containers),
|
|
178
|
-
...diffUi(before.app.ui, after.app.ui),
|
|
179
|
-
...diffHistory(before.history, after.history),
|
|
180
|
-
];
|
|
181
|
-
|
|
182
|
-
// Shares are derived from containers today. If future share metadata diverges,
|
|
183
|
-
// sync the whole snapshot rather than silently dropping ownership state.
|
|
184
|
-
if (!sameJson(before.shares, after.shares) && ops.length === 0) {
|
|
185
|
-
ops.push(makeSyncOp('snapshot.replace', { snapshot: after }));
|
|
186
|
-
}
|
|
187
|
-
return ops;
|
|
188
|
-
}
|