UNPKG

@retorquere/zotero-sync

Version:

One-way sync of Zotero libraries

141 lines (140 loc) 6.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Sync = void 0; const events = require("events"); const node_fetch_1 = require("node-fetch"); function enumerate(array) { return array.map((v, i) => [i, v]); } class Sync { constructor(batch = 50, emitter) { this.headers = { 'Zotero-API-Version': '3' }; this.libraries = {}; this.batch = batch; this.emitter = emitter || new events.EventEmitter(); } on(event, handler) { this.emitter.on(event, handler); } local() { const prefix = '/api/users/0'; this.libraries[prefix] = { type: 'local', prefix, name: '', }; this.userID = 0; } async login(api_key) { var _a, _b, _c, _d, _e, _f; this.headers.Authorization = `Bearer ${api_key}`; const account = await this.json('https://api.zotero.org/keys/current'); if ((_b = (_a = account.access) === null || _a === void 0 ? void 0 : _a.user) === null || _b === void 0 ? void 0 : _b.library) { const prefix = `/users/${account.userID}`; this.libraries[prefix] = { type: 'user', prefix, name: '', }; } if ((_c = account.access) === null || _c === void 0 ? void 0 : _c.groups) { for (const library of await this.json(`https://api.zotero.org/users/${account.userID}/groups`)) { if (((_e = (_d = account.access) === null || _d === void 0 ? void 0 : _d.groups) === null || _e === void 0 ? void 0 : _e.all) || ((_f = account.access) === null || _f === void 0 ? void 0 : _f.groups[library.id])) { const prefix = `/groups/${library.id}`; this.libraries[prefix] = { type: 'group', prefix, name: library.data.name, }; } } } this.userID = account.userID; } async fetch(url) { return await (0, node_fetch_1.default)(url, { headers: this.headers }); } async json(url) { return await (await this.fetch(url)).json(); // eslint-disable-line @typescript-eslint/no-unsafe-return } async get(prefix, uri) { const library = this.libraries[prefix]; if (!library) throw new Error(`${this.userID} does not have access to ${prefix}`); const baseUrl = (library.type === 'local') ? 'http://localhost:23119' : 'https://api.zotero.org'; uri = `${baseUrl}${prefix}${uri}`; const res = await this.fetch(uri); if (typeof library.version === 'number') { if (res.headers.get('last-modified-version') !== `${library.version}`) { throw new Error(`last-modified-version changed from ${library.version} to ${res.headers.get('last-modified-version')} during sync, retry later`); } } else { library.version = parseInt(res.headers.get('last-modified-version')); if (isNaN(library.version)) throw new Error(`${res.headers.get('last-modified-version')} is not a number`); } return await res.json(); // eslint-disable-line @typescript-eslint/no-unsafe-return } async sync(store, includeTrashed = true) { // remove libraries we no longer have access to const libraries = Object.keys(this.libraries); for (const user_or_group_prefix of store.libraries) { if (!user_or_group_prefix.startsWith('/users/') && !libraries.includes(user_or_group_prefix)) await store.remove(user_or_group_prefix); } // update all libraries for (const [n, [prefix, library]] of enumerate(Object.entries(this.libraries))) { this.emitter.emit(Sync.event.library, library, n + 1, libraries.length); try { await this.update(store, prefix, includeTrashed); } catch (err) { this.emitter.emit(Sync.event.error, err); } } } async update(store, prefix, includeTrashed) { const stored = await store.get(prefix); const remote = this.libraries[prefix]; if (remote.type !== 'local') { // local does not yet support deleted // first fetch also gets the remote version const deleted = await this.get(prefix, `/deleted?since=${stored.version}`); if (stored.version === remote.version) return; if (deleted.items.length) { this.emitter.emit(Sync.event.remove, 'items', deleted.items); await stored.remove(deleted.items); } if (deleted.collections.length) { this.emitter.emit(Sync.event.remove, 'collections', deleted.collections); await stored.remove_collections(deleted.collections); } } const items = Object.keys(await this.get(prefix, `/items?since=${stored.version}&format=versions&includeTrashed=${Number(includeTrashed)}`)); for (let n = 0; n < items.length; n++) { for (const item of await this.get(prefix, `/items?itemKey=${items.slice(n, n + this.batch).join(',')}&includeTrashed=${Number(includeTrashed)}`)) { await stored.add(item.data); n += 1; this.emitter.emit(Sync.event.item, item.data, n, items.length); } } const collections = Object.keys(await this.get(prefix, `/collections?since=${stored.version}&format=versions`)); for (let n = 0; n < collections.length; n++) { for (const collection of await this.get(prefix, `/collections?collectionKey=${collections.slice(n, n + this.batch).join(',')}`)) { await stored.add_collection(collection.data); n += 1; this.emitter.emit(Sync.event.collection, collection.data, n, collections.length); } } await stored.save(remote.type === 'group' ? remote.name : undefined, remote.version); } } exports.Sync = Sync; Sync.event = { library: 'zotero-sync.save-library', collection: 'zotero-sync.save-collection', remove: 'zotero-sync.remove-objects', item: 'zotero-sync.save-item', error: 'zotero-sync.error', };