tigertag 1.1.0 → 1.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/src/db.js CHANGED
@@ -21,15 +21,42 @@
21
21
  // https://github.com/TigerTag-Project/TigerTag-RFID-Guide/blob/main/LICENSING.md
22
22
 
23
23
  /**
24
- * TigerTag reference database loader and sync utilities.
24
+ * TigerTag reference tables (id_*.json): loader, precedence, automatic and
25
+ * manual updates.
26
+ *
27
+ * Where each table comes from (per file):
28
+ * 1. a custom folder (`dbPath`) — used EXCLUSIVELY: a missing file is an error,
29
+ * there is no fallback, and no network call happens unless update() is called;
30
+ * 2. otherwise the newest of
31
+ * - the downloaded copy in the data dir (`dataDir`, `TIGERTAG_DATA_DIR`,
32
+ * default: the per-user cache folder shared with the catalogue), and
33
+ * - the copy bundled in the npm package (always present),
34
+ * compared by their last_update.json timestamps.
35
+ *
36
+ * Updates: `new TigerTagDB()` never waits for the network. With autoUpdate on
37
+ * (default) it starts ONE background check per process, at most once per
38
+ * `maxAge` (default 1 day): one request to the TigerTag API (GitHub mirror as
39
+ * fallback), only the changed tables are downloaded into the data dir, ~5 s
40
+ * timeout, errors are swallowed. `await TigerTagDB.open()` does the same check
41
+ * before returning. `await db.update()` forces it. `offline: true` (or
42
+ * `TIGERTAG_OFFLINE=1`) means zero network calls.
25
43
  */
26
44
 
27
45
  const fs = require('fs');
28
46
  const path = require('path');
47
+ const { defaultDataDir, resolveOffline, BUNDLED_DIR } = require('./datadir');
29
48
 
30
49
  const _API_BASE = 'https://api.tigertag.io/api:tigertag';
31
50
  const _GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/TigerTag-Project/TigerTag-RFID-Guide/main/database';
32
- const _HTTP_TIMEOUT = 30000;
51
+ const _MANUAL_TIMEOUT = 30000;
52
+ const _AUTO_TIMEOUT = 5000;
53
+ const _RETRY_AFTER_FAILURE_MS = 3600 * 1000; // a failed automatic check is retried after 1 h
54
+
55
+ /** Default interval between two automatic checks: 1 day. */
56
+ const DEFAULT_UPDATE_MAX_AGE_MS = 24 * 3600 * 1000;
57
+
58
+ const _LAST_UPDATE = 'last_update.json';
59
+ const _STATE_FILE = 'db_state.json';
33
60
 
34
61
  // Maps last_update key → [API endpoint, local filename]
35
62
  const _DATASETS = {
@@ -42,202 +69,351 @@ const _DATASETS = {
42
69
  measure_units: ['measure_unit/get/all', 'id_measure_unit.json'],
43
70
  };
44
71
 
45
- const _BUNDLED_DB_PATH = path.join(__dirname, '..', 'database');
72
+ // Instance property holding each table
73
+ const _PROPS = {
74
+ versions: '_versions', types: '_types', brands: '_brands', filament_diameters: '_diameters',
75
+ filament_materials: '_materials', aspects: '_aspects', measure_units: '_units',
76
+ };
46
77
 
47
- /**
48
- * Download or update TigerTag reference JSON databases.
49
- * Tries the live TigerTag API first; falls back to the GitHub mirror.
50
- * Only downloads files whose timestamp has changed.
51
- *
52
- * @param {string} [dbPath] - Folder where JSON files are stored (created if missing).
53
- * @param {object} [options]
54
- * @param {boolean} [options.force=false] - Re-download all files even if up to date.
55
- * @param {boolean} [options.verbose=true] - Print progress to stdout.
56
- * @returns {Promise<string[]>} List of filenames that were downloaded/updated.
57
- */
58
- async function syncDatabases(dbPath, { force = false, verbose = true } = {}) {
59
- const resolvedPath = path.resolve(dbPath || _BUNDLED_DB_PATH);
60
- fs.mkdirSync(resolvedPath, { recursive: true });
78
+ const _BUNDLED_DB_PATH = BUNDLED_DIR;
79
+ const _backgroundChecks = new Map(); // dataDir → Promise (one automatic check per process)
61
80
 
62
- const lastUpdatePath = path.join(resolvedPath, 'last_update.json');
81
+ function _readJson(file) {
82
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
83
+ }
63
84
 
64
- const _log = verbose ? (msg) => console.log(msg) : () => {};
85
+ function _writeJson(file, data) {
86
+ fs.mkdirSync(path.dirname(file), { recursive: true });
87
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
88
+ }
65
89
 
66
- async function _get(url) {
67
- const controller = new AbortController();
68
- const timer = setTimeout(() => controller.abort(), _HTTP_TIMEOUT);
69
- try {
70
- const resp = await fetch(url, { signal: controller.signal });
71
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
72
- const text = await resp.text();
73
- return { data: JSON.parse(text), text };
74
- } finally {
75
- clearTimeout(timer);
76
- }
90
+ async function _getJson(url, fetchImpl, timeout) {
91
+ const f = fetchImpl || globalThis.fetch;
92
+ if (typeof f !== 'function') throw new Error('fetch() is not available (Node.js 18+ required)');
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), timeout);
95
+ try {
96
+ const resp = await f(url, { signal: controller.signal });
97
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
98
+ return JSON.parse(await resp.text());
99
+ } finally {
100
+ clearTimeout(timer);
77
101
  }
102
+ }
78
103
 
79
- let remoteData, remoteText, datasetUrlFn;
80
-
104
+ // One request for every table timestamp: TigerTag API first, GitHub mirror as fallback
105
+ async function _remoteLastUpdate(fetchImpl, timeout, log) {
81
106
  try {
82
- const result = await _get(`${_API_BASE}/all/last_update`);
83
- remoteData = result.data;
84
- remoteText = result.text;
85
- datasetUrlFn = (endpoint, _filename) => `${_API_BASE}/${endpoint}`;
86
- _log('[info] source: api');
107
+ const data = await _getJson(`${_API_BASE}/all/last_update`, fetchImpl, timeout);
108
+ log('[info] source: api');
109
+ return { data, source: 'api', url: (endpoint) => `${_API_BASE}/${endpoint}` };
87
110
  } catch (exc) {
88
- _log(`[warn] TigerTag API unreachable (${exc.message}), falling back to GitHub mirror`);
111
+ log(`[warn] TigerTag API unreachable (${exc.message}), falling back to GitHub mirror`);
89
112
  try {
90
- const result = await _get(`${_GITHUB_RAW_BASE}/last_update.json`);
91
- remoteData = result.data;
92
- remoteText = result.text;
93
- datasetUrlFn = (_endpoint, filename) => `${_GITHUB_RAW_BASE}/${filename}`;
94
- _log('[info] source: github');
113
+ const data = await _getJson(`${_GITHUB_RAW_BASE}/${_LAST_UPDATE}`, fetchImpl, timeout);
114
+ log('[info] source: github');
115
+ return { data, source: 'github', url: (_endpoint, filename) => `${_GITHUB_RAW_BASE}/${filename}` };
95
116
  } catch (exc2) {
96
117
  throw new Error(
97
- `Both API and GitHub mirror are unreachable.\nAPI error: ${exc.message}\nGitHub error: ${exc2.message}\nCheck your internet connection.`,
118
+ `Both API and GitHub mirror are unreachable.\nAPI error: ${exc.message}\n`
119
+ + `GitHub error: ${exc2.message}\nCheck your internet connection.`,
98
120
  );
99
121
  }
100
122
  }
123
+ }
101
124
 
102
- let localData = {};
103
- if (fs.existsSync(lastUpdatePath)) {
104
- try {
105
- localData = JSON.parse(fs.readFileSync(lastUpdatePath, 'utf8'));
106
- } catch (_) {}
107
- }
108
-
125
+ /**
126
+ * Download the changed tables into `dir`.
127
+ * `current(key, localLastUpdate)` gives the timestamp already available locally
128
+ * (null = nothing), a table is downloaded when the remote one is newer (or `force`).
129
+ * Returns the list of written files (last_update.json included when rewritten).
130
+ */
131
+ async function _updateTables(dir, { force = false, fetchImpl = null, timeout = _MANUAL_TIMEOUT, log = () => {}, current }) {
132
+ const remote = await _remoteLastUpdate(fetchImpl, timeout, log);
133
+ const lastUpdateFile = path.join(dir, _LAST_UPDATE);
134
+ const local = _readJson(lastUpdateFile) || {};
109
135
  const updated = [];
110
-
111
136
  for (const [key, [endpoint, filename]] of Object.entries(_DATASETS)) {
112
- const remoteTs = remoteData[key];
113
- const localTs = localData[key];
114
- const localFile = path.join(resolvedPath, filename);
115
-
116
- if (remoteTs == null) {
117
- _log(`[skip] ${key}: not in last_update payload`);
118
- continue;
119
- }
120
-
121
- if (!force && remoteTs === localTs && fs.existsSync(localFile)) {
122
- _log(`[ok] ${filename}: up to date`);
123
- continue;
124
- }
125
-
126
- _log(`[sync] ${filename}: ${localTs} → ${remoteTs}`);
127
- const controller = new AbortController();
128
- const timer = setTimeout(() => controller.abort(), _HTTP_TIMEOUT);
129
- try {
130
- const resp = await fetch(datasetUrlFn(endpoint, filename), { signal: controller.signal });
131
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
132
- const data = await resp.json();
133
- fs.writeFileSync(localFile, JSON.stringify(data, null, 2), 'utf8');
134
- updated.push(filename);
135
- } finally {
136
- clearTimeout(timer);
137
- }
137
+ const remoteTs = remote.data[key];
138
+ if (remoteTs == null) { log(`[skip] ${key}: not in last_update payload`); continue; }
139
+ const have = current(key, local);
140
+ if (!force && have != null && have >= remoteTs) { log(`[ok] ${filename}: up to date`); continue; }
141
+ log(`[sync] ${filename}: ${have} → ${remoteTs}`);
142
+ const data = await _getJson(remote.url(endpoint, filename), fetchImpl, timeout);
143
+ if (!Array.isArray(data)) throw new Error(`${filename}: expected a JSON list`);
144
+ _writeJson(path.join(dir, filename), data);
145
+ local[key] = remoteTs;
146
+ updated.push(filename);
138
147
  }
139
-
140
- if (updated.length > 0 || JSON.stringify(localData) !== JSON.stringify(remoteData)) {
141
- fs.writeFileSync(lastUpdatePath, remoteText, 'utf8');
142
- if (!updated.includes('last_update.json')) updated.push('last_update.json');
148
+ if (updated.length) {
149
+ _writeJson(lastUpdateFile, local);
150
+ updated.push(_LAST_UPDATE);
143
151
  }
152
+ return { updated, source: remote.source };
153
+ }
144
154
 
155
+ /**
156
+ * Download or update the reference tables in one folder (e.g. a custom dbPath).
157
+ * Tries the live TigerTag API first; falls back to the GitHub mirror.
158
+ * Only downloads files whose timestamp has changed.
159
+ *
160
+ * @param {string} [dbPath] - Folder where JSON files are stored (created if missing).
161
+ * Default: the data dir (see TigerTagDB).
162
+ * @param {object} [options]
163
+ * @param {boolean} [options.force=false] - Re-download all files even if up to date.
164
+ * @param {boolean} [options.verbose=true] - Print progress to stdout.
165
+ * @param {number} [options.timeout=30000] - Per-request timeout in ms.
166
+ * @param {Function} [options.fetch] - fetch implementation (default: global fetch).
167
+ * @returns {Promise<string[]>} List of filenames that were downloaded/updated.
168
+ * @throws {Error} When both the API and the GitHub mirror are unreachable.
169
+ */
170
+ async function syncDatabases(dbPath, { force = false, verbose = true, timeout = _MANUAL_TIMEOUT, fetch = null } = {}) {
171
+ const dir = path.resolve(dbPath || defaultDataDir());
172
+ fs.mkdirSync(dir, { recursive: true });
173
+ const log = verbose ? (msg) => console.log(msg) : () => {};
174
+ const { updated } = await _updateTables(dir, {
175
+ force, fetchImpl: fetch, timeout, log,
176
+ current: (key, local) => (fs.existsSync(path.join(dir, _DATASETS[key][1])) ? (local[key] ?? null) : null),
177
+ });
145
178
  return updated;
146
179
  }
147
180
 
148
181
  /**
149
182
  * Loads and exposes TigerTag JSON reference databases.
150
183
  *
151
- * Ships with bundled JSON files so the SDK works offline immediately after install.
152
- * Optionally syncs updates from the TigerTag API or GitHub mirror via sync().
184
+ * Ships with bundled JSON files so the SDK works offline immediately after install,
185
+ * keeps a fresher downloaded copy in the data dir, and checks for updates once a day.
153
186
  *
154
187
  * All ID lookups return the full JSON entry object (or null if not found).
155
188
  * The JSON files are the single source of truth — no hardcoded ID mappings.
156
189
  *
157
190
  * @example
158
- * const db = new TigerTagDB();
191
+ * const db = new TigerTagDB(); // local data, background daily check
192
+ * const db = await TigerTagDB.open(); // waits for the daily check (5 s max)
193
+ * const db = new TigerTagDB({ offline: true }); // never touches the network
159
194
  * const mat = db.material(38219);
160
195
  * console.log(mat.label); // "PLA"
161
- * console.log(mat.density); // 1.24
162
196
  */
163
197
  class TigerTagDB {
164
198
  /**
199
+ * Synchronous and network-free: loads the freshest LOCAL copy of every table.
200
+ * With autoUpdate on, a background daily check is started (see `ready`).
201
+ *
165
202
  * @param {object} [options]
166
- * @param {string} [options.dbPath] - Path to database directory. Defaults to bundled DB.
167
- * @param {boolean} [options.autoSync] - Flag; call db.sync() manually to download files.
168
- * @param {boolean} [options.verbose] - Print sync progress (default true).
203
+ * @param {string} [options.dbPath] - Custom folder, used exclusively (missing file → Error,
204
+ * no fallback, no automatic network call).
205
+ * @param {string} [options.dataDir] - Folder for downloaded copies (default: TIGERTAG_DATA_DIR,
206
+ * TIGERTAG_CACHE_DIR, or the per-user cache folder).
207
+ * @param {boolean} [options.offline] - true = zero network calls (default: TIGERTAG_OFFLINE env).
208
+ * @param {boolean} [options.autoUpdate=true] - Automatic daily check (background in the constructor).
209
+ * @param {boolean} [options.autoSync] - Deprecated alias of autoUpdate.
210
+ * @param {number} [options.maxAge=1 day] - Minimum interval between two automatic checks, in ms.
211
+ * @param {boolean} [options.verbose=false] - Log update progress / errors.
212
+ * @param {Function} [options.fetch] - fetch implementation (default: global fetch).
213
+ * @throws {Error} When a custom dbPath is missing a table or a table cannot be parsed.
169
214
  */
170
- constructor({ dbPath, autoSync = false, verbose = true } = {}) {
171
- this._path = dbPath ? path.resolve(dbPath) : _BUNDLED_DB_PATH;
172
- this._autoSync = autoSync;
173
- this._verbose = verbose;
174
- this._ensureDb();
175
- this._versions = this._load('id_version.json');
176
- this._materials = this._load('id_material.json');
177
- this._aspects = this._load('id_aspect.json');
178
- this._types = this._load('id_type.json');
179
- this._diameters = this._load('id_diameter.json');
180
- this._brands = this._load('id_brand.json');
181
- this._units = this._load('id_measure_unit.json');
215
+ constructor({
216
+ dbPath, dataDir, offline, autoUpdate, autoSync, maxAge = DEFAULT_UPDATE_MAX_AGE_MS,
217
+ verbose = false, fetch = null,
218
+ } = {}) {
219
+ this._custom = dbPath ? path.resolve(dbPath) : null;
220
+ this._path = this._custom || _BUNDLED_DB_PATH; // kept for backward compatibility
221
+ this._dataDir = path.resolve(dataDir || defaultDataDir());
222
+ this._offline = resolveOffline(offline);
223
+ this._autoUpdate = autoUpdate ?? autoSync ?? true;
224
+ this._maxAge = maxAge;
225
+ this._verbose = verbose;
226
+ this._fetch = fetch;
227
+ this._reloadAll();
228
+ /** Resolves (to this instance) when the background check started by the constructor is done. */
229
+ this.ready = Promise.resolve(this);
230
+ if (this._autoUpdate && this._canAutoUpdate()) {
231
+ let p = _backgroundChecks.get(this._dataDir);
232
+ if (!p) {
233
+ p = this._autoCheck();
234
+ _backgroundChecks.set(this._dataDir, p);
235
+ }
236
+ this.ready = p.then(() => { this._reloadAll(); return this; }, () => this);
237
+ }
182
238
  }
183
239
 
184
- _ensureDb() {
185
- const missing = TigerTagDB.REQUIRED_FILES.filter(
186
- (fn) => !fs.existsSync(path.join(this._path, fn)),
187
- );
188
- if (missing.length === 0) return;
189
-
190
- // Fallback to bundled database if user provided a custom path
191
- if (this._path !== _BUNDLED_DB_PATH && fs.existsSync(_BUNDLED_DB_PATH)) {
192
- const stillMissing = TigerTagDB.REQUIRED_FILES.filter(
193
- (fn) => !fs.existsSync(path.join(_BUNDLED_DB_PATH, fn)),
194
- );
195
- if (stillMissing.length === 0) {
196
- this._path = _BUNDLED_DB_PATH;
197
- return;
198
- }
240
+ /**
241
+ * Create a TigerTagDB and wait for the automatic daily check (at most once per
242
+ * maxAge, ~5 s timeout, never throws) before returning it.
243
+ *
244
+ * @param {object} [options] - Same options as the constructor.
245
+ * @returns {Promise<TigerTagDB>}
246
+ */
247
+ static async open(options = {}) {
248
+ const db = new TigerTagDB({ ...options, autoUpdate: false });
249
+ db._autoUpdate = options.autoUpdate ?? options.autoSync ?? true;
250
+ if (db._autoUpdate && db._canAutoUpdate()) {
251
+ await db._autoCheck();
252
+ db._reloadAll();
199
253
  }
254
+ return db;
255
+ }
256
+
257
+ _canAutoUpdate() { return !this._offline && !this._custom; }
258
+
259
+ _log(msg) { if (this._verbose) console.log(msg); }
260
+
261
+ // Timestamp already available locally for a table (newest of data dir and bundled copy)
262
+ _currentTs(key, dataLastUpdate) {
263
+ const bundled = (_readJson(path.join(_BUNDLED_DB_PATH, _LAST_UPDATE)) || {})[key] ?? null;
264
+ const file = path.join(this._dataDir, _DATASETS[key][1]);
265
+ const dl = fs.existsSync(file) ? (dataLastUpdate[key] ?? null) : null;
266
+ if (dl == null) return bundled;
267
+ if (bundled == null) return dl;
268
+ return Math.max(dl, bundled);
269
+ }
270
+
271
+ _state() { return _readJson(path.join(this._dataDir, _STATE_FILE)) || {}; }
200
272
 
201
- process.stderr.write(
202
- `\nTigerTag database files not found.\n Expected: ${path.resolve(this._path)}\n\n Missing:\n`
203
- + missing.map((fn) => ` - ${fn}`).join('\n')
204
- + '\n\n Call await db.sync() to download them.\n\n',
205
- );
273
+ _saveState(patch) {
274
+ try { _writeJson(path.join(this._dataDir, _STATE_FILE), { ...this._state(), ...patch }); } catch { /* read-only */ }
206
275
  }
207
276
 
208
- _load(filename) {
209
- const fp = path.join(this._path, filename);
210
- if (!fs.existsSync(fp)) return [];
277
+ // Automatic check: throttled by maxAge (1 h after a failure), never throws
278
+ async _autoCheck() {
279
+ const st = _readJson(path.join(this._dataDir, _STATE_FILE)) || {};
280
+ const now = Date.now();
281
+ if (st.lastCheck && now - Date.parse(st.lastCheck) < this._maxAge) return [];
282
+ if (st.lastAttempt && st.lastError && now - Date.parse(st.lastAttempt) < Math.min(this._maxAge, _RETRY_AFTER_FAILURE_MS)) return [];
283
+ const at = new Date().toISOString();
211
284
  try {
212
- return JSON.parse(fs.readFileSync(fp, 'utf8'));
213
- } catch (_) {
285
+ const { updated, source } = await _updateTables(this._dataDir, {
286
+ fetchImpl: this._fetch, timeout: _AUTO_TIMEOUT, log: (m) => this._log(m),
287
+ current: (key, local) => this._currentTs(key, local),
288
+ });
289
+ this._saveState({ lastCheck: at, lastAttempt: at, lastError: null, source, lastUpdated: updated });
290
+ return updated;
291
+ } catch (err) {
292
+ this._log(`[warn] automatic reference data check failed: ${err.message}`);
293
+ this._saveState({ lastAttempt: at, lastError: err.message });
214
294
  return [];
215
295
  }
216
296
  }
217
297
 
218
- _reloadAll() {
219
- this._versions = this._load('id_version.json');
220
- this._materials = this._load('id_material.json');
221
- this._aspects = this._load('id_aspect.json');
222
- this._types = this._load('id_type.json');
223
- this._diameters = this._load('id_diameter.json');
224
- this._brands = this._load('id_brand.json');
225
- this._units = this._load('id_measure_unit.json');
226
- }
227
-
228
- static _find(table, idValue) {
229
- return table.find((e) => e.id === idValue) || null;
298
+ /**
299
+ * Check for new reference tables now and download the changed ones.
300
+ * Targets the custom dbPath when one is set, otherwise the data dir.
301
+ *
302
+ * @param {object} [options]
303
+ * @param {boolean} [options.force=false] - Re-download every table even if up to date.
304
+ * @param {boolean} [options.catalog=false] - Also check for a new product catalogue.
305
+ * @param {number} [options.timeout=30000] - Per-request timeout in ms.
306
+ * @returns {Promise<string[]>} Files written (tables, last_update.json, id_catalog.json).
307
+ * @throws {Error} When offline, or when the API and the GitHub mirror are both unreachable.
308
+ */
309
+ async update({ force = false, catalog = false, timeout = _MANUAL_TIMEOUT } = {}) {
310
+ if (this._offline) {
311
+ throw new Error('TigerTagDB is offline (offline: true or TIGERTAG_OFFLINE=1): update() makes no network call.');
312
+ }
313
+ const dir = this._custom || this._dataDir;
314
+ const at = new Date().toISOString();
315
+ const { updated, source } = await _updateTables(dir, {
316
+ force, fetchImpl: this._fetch, timeout, log: (m) => this._log(m),
317
+ current: this._custom
318
+ ? (key, local) => (fs.existsSync(path.join(dir, _DATASETS[key][1])) ? (local[key] ?? null) : null)
319
+ : (key, local) => (force ? null : this._currentTs(key, local)),
320
+ });
321
+ if (!this._custom) this._saveState({ lastCheck: at, lastAttempt: at, lastError: null, source, lastUpdated: updated });
322
+ if (catalog) {
323
+ const { refreshCatalog, catalogInfo } = require('./catalog');
324
+ const before = catalogInfo({ dataDir: this._dataDir }).fetchedAt;
325
+ await refreshCatalog({ dataDir: this._dataDir, fetch: this._fetch, offline: false });
326
+ if (catalogInfo({ dataDir: this._dataDir }).fetchedAt !== before) updated.push('id_catalog.json');
327
+ }
328
+ this._reloadAll();
329
+ return updated;
230
330
  }
231
331
 
232
332
  /**
233
- * Manually trigger a database update.
333
+ * Deprecated alias of update(): check for new tables now.
234
334
  * @param {boolean} [force=false] - Re-download all files even if up to date.
235
335
  * @returns {Promise<string[]>} List of filenames that were downloaded/updated.
236
336
  */
237
337
  async sync(force = false) {
238
- const updated = await syncDatabases(this._path, { force, verbose: this._verbose });
239
- this._reloadAll();
240
- return updated;
338
+ return this.update({ force });
339
+ }
340
+
341
+ // Resolve every table: custom folder exclusively, else newest of data dir / bundled
342
+ _resolve() {
343
+ const out = {};
344
+ if (this._custom) {
345
+ const lu = _readJson(path.join(this._custom, _LAST_UPDATE)) || {};
346
+ const missing = Object.values(_DATASETS).map(([, fn]) => fn)
347
+ .filter((fn) => !fs.existsSync(path.join(this._custom, fn)));
348
+ if (missing.length) {
349
+ throw new Error(
350
+ `TigerTag database folder ${this._custom} is missing: ${missing.join(', ')}. A custom dbPath is `
351
+ + 'used exclusively (no fallback to the bundled copy). Copy the files there, or download them with '
352
+ + `syncDatabases('${this._custom}') / tigertag update --db "${this._custom}".`,
353
+ );
354
+ }
355
+ for (const [key, [, fn]] of Object.entries(_DATASETS)) {
356
+ out[key] = { file: fn, source: 'custom', path: path.join(this._custom, fn), timestamp: lu[key] ?? null };
357
+ }
358
+ return out;
359
+ }
360
+ const bundledLu = _readJson(path.join(_BUNDLED_DB_PATH, _LAST_UPDATE)) || {};
361
+ const dataLu = _readJson(path.join(this._dataDir, _LAST_UPDATE)) || {};
362
+ for (const [key, [, fn]] of Object.entries(_DATASETS)) {
363
+ const dlFile = path.join(this._dataDir, fn);
364
+ const dlTs = fs.existsSync(dlFile) ? (dataLu[key] ?? null) : null;
365
+ const bTs = bundledLu[key] ?? null;
366
+ out[key] = dlTs != null && (bTs == null || dlTs > bTs)
367
+ ? { file: fn, source: 'downloaded', path: dlFile, timestamp: dlTs }
368
+ : { file: fn, source: 'bundled', path: path.join(_BUNDLED_DB_PATH, fn), timestamp: bTs };
369
+ }
370
+ return out;
371
+ }
372
+
373
+ _reloadAll() {
374
+ this._sources = this._resolve();
375
+ for (const [key, src] of Object.entries(this._sources)) {
376
+ let data = _readJson(src.path);
377
+ if (!Array.isArray(data) && src.source === 'downloaded') { // broken download → bundled copy
378
+ const fb = path.join(_BUNDLED_DB_PATH, src.file);
379
+ data = _readJson(fb);
380
+ this._sources[key] = { ...src, source: 'bundled', path: fb };
381
+ }
382
+ if (!Array.isArray(data)) {
383
+ if (src.source === 'custom') throw new Error(`TigerTag database file ${src.path} is not a valid JSON list.`);
384
+ data = [];
385
+ }
386
+ this[_PROPS[key]] = data;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Where every table comes from, and the update / catalogue status.
392
+ *
393
+ * @returns {{ offline: boolean, autoUpdate: boolean, maxAge: number, dataDir: string, customDir: string|null,
394
+ * lastCheck: string|null, lastAttempt: string|null, lastError: string|null,
395
+ * tables: Object<string, { file: string, source: 'custom'|'downloaded'|'bundled', path: string,
396
+ * timestamp: number|null }>, catalog: object }}
397
+ */
398
+ info() {
399
+ const st = this._custom ? {} : this._state();
400
+ const { catalogInfo } = require('./catalog');
401
+ return {
402
+ offline: this._offline,
403
+ autoUpdate: this._autoUpdate,
404
+ maxAge: this._maxAge,
405
+ dataDir: this._dataDir,
406
+ customDir: this._custom,
407
+ lastCheck: st.lastCheck || null,
408
+ lastAttempt: st.lastAttempt || null,
409
+ lastError: st.lastError || null,
410
+ tables: JSON.parse(JSON.stringify(this._sources)),
411
+ catalog: catalogInfo({ dataDir: this._dataDir }),
412
+ };
413
+ }
414
+
415
+ static _find(table, idValue) {
416
+ return table.find((e) => e.id === idValue) || null;
241
417
  }
242
418
 
243
419
  /**
@@ -302,4 +478,4 @@ class TigerTagDB {
302
478
 
303
479
  TigerTagDB.REQUIRED_FILES = Object.values(_DATASETS).map(([, fn]) => fn);
304
480
 
305
- module.exports = { TigerTagDB, syncDatabases, _BUNDLED_DB_PATH };
481
+ module.exports = { TigerTagDB, syncDatabases, _BUNDLED_DB_PATH, DEFAULT_UPDATE_MAX_AGE_MS };
package/src/index.js CHANGED
@@ -24,7 +24,7 @@
24
24
  * tigertag — JavaScript SDK for TigerTag RFID material identification.
25
25
  *
26
26
  * Spec : https://github.com/TigerTag-Project/TigerTag-RFID-Guide
27
- * Protocol: TigerTag Open Source v2.1
27
+ * Protocol: TigerTag Open Source v2.2
28
28
  *
29
29
  * Quick start:
30
30
  * const { TigerTag } = require('tigertag');
@@ -37,6 +37,8 @@
37
37
  * console.log(tag.toRawDict()); // raw protocol fields
38
38
  * console.log(tag.toDict()); // enriched with labels, hex colors, dates
39
39
  * console.log(tag.verify()); // ECDSA signature result
40
+ *
41
+ * const plus = await TigerTag.fromCatalog(3527039449); // TigerTag+ from the catalogue
40
42
  */
41
43
 
42
44
  const {
@@ -54,7 +56,11 @@ const {
54
56
  } = require('./tag');
55
57
 
56
58
  const { TigerTagDB, syncDatabases } = require('./db');
59
+ const { defaultDataDir } = require('./datadir');
57
60
  const { SignatureResult, ecdsaRawToDer } = require('./signature');
61
+ const {
62
+ CATALOG_URL, loadCatalog, refreshCatalog, catalogInfo, catalogEntry, defaultCacheDir: catalogCacheDir,
63
+ } = require('./catalog');
58
64
 
59
65
  module.exports = {
60
66
  TigerTag,
@@ -62,7 +68,14 @@ module.exports = {
62
68
  SignatureResult,
63
69
  ApiDiff,
64
70
  syncDatabases,
71
+ defaultDataDir,
65
72
  ecdsaRawToDer,
73
+ loadCatalog,
74
+ refreshCatalog,
75
+ catalogInfo,
76
+ catalogEntry,
77
+ catalogCacheDir,
78
+ CATALOG_URL,
66
79
  CHIP_DUMP_LEN,
67
80
  FULL_DATA_LEN,
68
81
  MIN_DATA_LEN,
package/src/signature.js CHANGED
@@ -65,7 +65,7 @@ class SignatureResult {
65
65
  }
66
66
 
67
67
  toString() {
68
- const base = SignatureResult._ICONS[this.status] || `? ${this.status}`;
68
+ const base = SignatureResult._LABELS[this.status] || `? ${this.status}`;
69
69
  return this.detail ? `${base} ${this.detail}` : base;
70
70
  }
71
71
 
@@ -85,13 +85,13 @@ SignatureResult.NO_CRYPTO = 'no_crypto';
85
85
  SignatureResult.NO_KEY = 'no_key';
86
86
  SignatureResult.NO_UID = 'no_uid';
87
87
 
88
- SignatureResult._ICONS = {
89
- valid: '✅ VALID',
90
- invalid: '❌ INVALID',
91
- unsigned: '⬜ NOT SIGNED',
92
- no_crypto: '⚠️ crypto not available',
93
- no_key: '⚠️ public key not found in id_version.json',
94
- no_uid: '⚠️ UID unavailable — provide a full 180-byte chip dump',
88
+ SignatureResult._LABELS = {
89
+ valid: 'VALID',
90
+ invalid: 'INVALID',
91
+ unsigned: 'NOT SIGNED',
92
+ no_crypto: 'NO CRYPTO — crypto not available',
93
+ no_key: 'NO PUBLIC KEY — public key not found in id_version.json',
94
+ no_uid: 'NO UID — UID unavailable, provide a full 180-byte chip dump',
95
95
  };
96
96
 
97
97
  /**