@cboulanger/zotero-sync-bookends
Advanced tools
Comparing version 1.0.1 to 2.0.1
{ | ||
"name": "@cboulanger/zotero-sync-bookends", | ||
"version": "1.0.1", | ||
"version": "2.0.1", | ||
"description": "Bookends store for @retorquere/zotero-sync", | ||
@@ -5,0 +5,0 @@ "main": "src/index.ts", |
@@ -16,7 +16,37 @@ # Bookends Store for @retorquere/zotero-sync | ||
> Note: This is currently a proof of concept not suitable for production use. Please | ||
> let me know if it works for you and open issues / pull requests if it doesn't. | ||
See [the test script](test.ts) for an example on how to integrate the library in your project. | ||
## Issues | ||
- Access to Bookends via OSA/JXA is slow. It can take a couple of hours to synchronize large libraries. | ||
- The current implementation works well enough for my use case but of course isn't perfect. Please | ||
let me know if it works for you and open issues / pull requests if it doesn't. | ||
- Bookends crashes quite a bit - see below how to recover from the crash without having to restart the | ||
synchronization from scratch. | ||
- Make sure not to modify a library on zotero.org during the initial synchronization, since otherwise | ||
synchronization will abort with a "last-modified-version changed ... retry later" error. | ||
- The implementation stores sync metadata such as the Zotero library version in the Bookends | ||
group names. This isn't a very robust solution but since there is no other place in Bookends | ||
for this kind of data, the best one I could come up with. | ||
## Recovering from a Bookends crash | ||
Unfortunately, especially when syncing large libraries, Bookends sometimes crashes and messes up all the groups. | ||
You can restore the state before the crash like so: | ||
1) Rebuild the library | ||
2) If the groups have disappeared, execute "Flatten Groups List Hierarchy" from the menu in the bottom left corner of | ||
the application window. Now the groups should re-appear. | ||
3) The group names (and the metadata stored in them) often need to be manually repaired. Double-click on them. | ||
For every library that had been fully synchronized before the crash, set the `lastIndex` value to 0 and the | ||
`version` value to the value of `library.version` displayed at `https://api.zotero.org/<prefix>/items/top?key=<your key>` | ||
(the `prefix` is also stored in the group name). You might also need to repair the group name, which is available | ||
from the same JSON data as `library.name` | ||
Then you can restart the synchronization. It will skip and update the previously synchronized libraries. For the others, | ||
it will "fast-forward" (sort of) to the not-yet-synchronized items if the "lastIndex" value has been preserved. | ||
## Testing | ||
The test will actually work out-of-the-box to sync your data if you provide the needed environment variables. | ||
```bash | ||
@@ -30,2 +60,7 @@ git clone https://github.com/cboulanger/zotero-sync-bookends.git | ||
See [the test script](test.ts) for an example on how to integrate the library in your project. | ||
## Resources | ||
- [JXA Release notes](https://developer.apple.com/library/archive/releasenotes/InterapplicationCommunication/RN-JavaScriptForAutomation/Articles/OSX10-10.html#//apple_ref/doc/uid/TP40014508-CH109-SW1) | ||
- [JXA debugging](https://developer.apple.com/library/archive/releasenotes/InterapplicationCommunication/RN-JavaScriptForAutomation/Articles/OSX10-11.html#//apple_ref/doc/uid/TP40014508-CH110-SW1) | ||
- [AppleEvents Error Codes](https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/reference/ASLR_error_codes.html) |
@@ -110,3 +110,3 @@ /** | ||
}, | ||
accessDate: 'user20', | ||
accessDate: false, | ||
abstractNote: 'abstract', | ||
@@ -128,3 +128,3 @@ authors: { | ||
bookTitle: 'volume', | ||
collections: 'groups', | ||
collections: "user20", | ||
conferenceName: 'journal', | ||
@@ -406,3 +406,3 @@ callNumber: 'user5', | ||
user16: false, | ||
user19: false, | ||
user19: "collections", | ||
volume: { | ||
@@ -409,0 +409,0 @@ translateName: function (data) { |
@@ -134,3 +134,7 @@ /** | ||
bookTitle: 'bookTitle', | ||
collections: false, | ||
collections: { | ||
default: () => [], | ||
translateName: () => 'collections', | ||
translateContent: data => data.split(",") | ||
}, | ||
conferenceName: 'conferenceName', | ||
@@ -328,4 +332,4 @@ callNumber: 'callNumber', | ||
*/ | ||
const fields_toGlobal = | ||
{ key: { | ||
const fields_toGlobal ={ | ||
key: { | ||
translateName : function(data) { | ||
@@ -355,2 +359,6 @@ return false; | ||
abstractNote: 'abstractNote', | ||
collections: { | ||
translateName: () => 'collections', | ||
translateContent: data => data.collections.join(",") | ||
}, | ||
creators: { | ||
@@ -357,0 +365,0 @@ translateName: function() {return false;}, // field name depends on content |
@@ -5,2 +5,3 @@ import type { Zotero } from "@retorquere/zotero-sync/typings/zotero"; | ||
fileName: string; | ||
static verbose: boolean; | ||
constructor(fileName: string); | ||
@@ -27,7 +28,19 @@ /** | ||
version: number; | ||
maxTries: number; | ||
private readonly prefix; | ||
private readonly store; | ||
private groupName?; | ||
private fastForwardTo; | ||
private lastIndex; | ||
private isNew; | ||
private synchronizingMessage; | ||
constructor(store: Store, user_or_group_prefix: string); | ||
/** | ||
* Output additional information on the console if the environment variable | ||
* ZOTERO_SYNC_DEBUG is set | ||
* @param {string} msg | ||
* @private | ||
*/ | ||
private debug; | ||
/** | ||
* Runs a JXA script in the context of the current Bookends library. The following | ||
@@ -52,6 +65,20 @@ * constants are predefined when running the passed script fragement: | ||
/** | ||
* Deletes the Bookends group containing the library items | ||
* Adds a Bookends group | ||
* @param groupName | ||
*/ | ||
delete(): Promise<void>; | ||
protected addGroup(groupName: string): Promise<void>; | ||
/** | ||
* Renames a Bookends group | ||
* @param oldGroupName | ||
* @param newGroupName | ||
* @protected | ||
*/ | ||
protected renameGroup(oldGroupName: string, newGroupName: string): Promise<void>; | ||
/** | ||
* Deletes a Bookends group | ||
* @param groupName | ||
* @protected | ||
*/ | ||
protected removeGroup(groupName: string): Promise<void>; | ||
/** | ||
* Parse the metadata stored in a group name | ||
@@ -65,2 +92,3 @@ * @protected | ||
prefix: string; | ||
lastIndex: number; | ||
}; | ||
@@ -70,6 +98,6 @@ }; | ||
* Store metadata in the group name | ||
* @param name | ||
* @param {string?} name | ||
* @protected | ||
*/ | ||
protected generateGroupName(name: string): string; | ||
protected generateGroupName(name?: string): string; | ||
/** | ||
@@ -83,2 +111,6 @@ * Finds a group name by the Zotero library prefix in the contained metadata | ||
/** | ||
* Removes the Bookends group containing the library items | ||
*/ | ||
delete(): Promise<void>; | ||
/** | ||
* Adds a Zotero collection object | ||
@@ -100,3 +132,5 @@ * @param {Zotero.Collection} collection | ||
*/ | ||
protected generateCitekey(item: Zotero.Item.Any): string; | ||
protected generateCitekey(item: Zotero.Item.Any | { | ||
key: string; | ||
}): string; | ||
/** | ||
@@ -108,10 +142,38 @@ * Translates a zotero item to data that can be imported into bookends | ||
*/ | ||
protected zoteroToBookends(item: Zotero.Item.Any | any, citekey: string): object; | ||
protected zoteroToBookends(item: Zotero.Item.Any | any, citekey: string): { | ||
[key: string]: string; | ||
}; | ||
/** | ||
* Returns the id of the item with the given citeky, or undefined if none such item exists | ||
* Returns the data of the publication item with the given citeky, or undefined if none such item exists | ||
* @param citekey | ||
* @protected | ||
*/ | ||
getIdByCitekey(citekey: string): Promise<string | undefined>; | ||
protected getPublicationByCitekey(citekey: string): Promise<{ | ||
[key: string]: string; | ||
} | undefined>; | ||
/** | ||
* Adds a publication item and links it to the current group | ||
* @param {object} data | ||
* @param {string?} groupName Optional name of the group, by default the group that contains the library items | ||
* @protected | ||
*/ | ||
protected addPublication(data: { | ||
[key: string]: string; | ||
}, groupName?: string): Promise<void>; | ||
/** | ||
* Updates a publication item | ||
* @param {number} id | ||
* @param {object} data | ||
* @protected | ||
*/ | ||
protected updatePublication(id: number, data: { | ||
[key: string]: string; | ||
}): Promise<void>; | ||
/** | ||
* Deletes a publication item | ||
* @param id | ||
* @protected | ||
*/ | ||
protected removePublication(id: number): Promise<void>; | ||
/** | ||
* Adds or updates a Zotero item object | ||
@@ -122,3 +184,3 @@ * @param {Zotero.Item.Any} item | ||
/** | ||
* Removes an Zotero item object | ||
* Removes Zotero item objects | ||
* @param {string[]} keys | ||
@@ -128,4 +190,9 @@ */ | ||
/** | ||
* Saves the Library | ||
* @param {String?} name Descriptive Name of the library | ||
* Saves the library metadata in the group name | ||
* @protected | ||
*/ | ||
protected saveMetadata(): Promise<void>; | ||
/** | ||
* Saves the Library metadata at the end of the sync process | ||
* @param {String} name Descriptive Name of the library | ||
* @param {Number} version | ||
@@ -132,0 +199,0 @@ */ |
247
src/index.js
@@ -9,2 +9,3 @@ "use strict"; | ||
const { Translator, zoteroDictionary, bookendsDictionary } = require('./dictionaries/translator'); | ||
const process = require('process'); | ||
class Store { | ||
@@ -39,2 +40,3 @@ constructor(fileName) { | ||
exports.Store = Store; | ||
Store.verbose = false; // set this to true if you want to have verbose output | ||
/** | ||
@@ -48,2 +50,8 @@ * Implementation of a Zotero library object | ||
this.version = 0; | ||
// public properties | ||
this.maxTries = 3; // how often to retry an OSA command if it times out | ||
this.fastForwardTo = 0; | ||
this.lastIndex = 0; | ||
this.isNew = false; | ||
this.synchronizingMessage = "Synchronizing..."; | ||
this.store = store; | ||
@@ -53,2 +61,15 @@ this.prefix = user_or_group_prefix; | ||
/** | ||
* Output additional information on the console if the environment variable | ||
* ZOTERO_SYNC_DEBUG is set | ||
* @param {string} msg | ||
* @private | ||
*/ | ||
debug(msg) { | ||
if (Store.verbose) { | ||
process.stdout.clearLine(); | ||
process.stdout.cursorTo(0); | ||
console.log(msg); | ||
} | ||
} | ||
/** | ||
* Runs a JXA script in the context of the current Bookends library. The following | ||
@@ -84,2 +105,3 @@ * constants are predefined when running the passed script fragement: | ||
this.groupName = await this.findGroupNameByPrefix(); | ||
this.lastIndex = 0; | ||
if (this.groupName) { | ||
@@ -89,6 +111,13 @@ const { name, data } = this.parseGroupName(this.groupName); | ||
this.version = data.version; | ||
if (this.version === 0) { | ||
// a case of an aborted initial sync | ||
this.isNew = true; | ||
} | ||
this.fastForwardTo = data.lastIndex || 0; | ||
} | ||
else { | ||
this.groupName = this.generateGroupName("Synchronizing..."); | ||
await this.run(`libraryWindow.groupItems.push(bookends.GroupItem({name:\`${this.groupName}\`}));`); | ||
this.isNew = true; | ||
this.name = this.synchronizingMessage; | ||
this.groupName = this.generateGroupName(); | ||
await this.addGroup(this.groupName); | ||
} | ||
@@ -98,10 +127,31 @@ return this; | ||
/** | ||
* Deletes the Bookends group containing the library items | ||
* Adds a Bookends group | ||
* @param groupName | ||
*/ | ||
async delete() { | ||
async addGroup(groupName) { | ||
await this.run(`libraryWindow.groupItems.push(bookends.GroupItem({name:\`${groupName}\`}));`); | ||
} | ||
/** | ||
* Renames a Bookends group | ||
* @param oldGroupName | ||
* @param newGroupName | ||
* @protected | ||
*/ | ||
async renameGroup(oldGroupName, newGroupName) { | ||
await this.run(`libraryWindow.groupItems.byName(\`${oldGroupName}\`).setProperty("name",\`${newGroupName}\`);`); | ||
} | ||
/** | ||
* Deletes a Bookends group | ||
* @param groupName | ||
* @protected | ||
*/ | ||
async removeGroup(groupName) { | ||
try { | ||
await this.run(`bookends.delete(libraryWindows.groupItems.byName(\`${this.groupName}\`))`); | ||
await this.run(`bookends.delete(libraryWindow.groupItems.byName(\`${groupName}\`))`); | ||
} | ||
catch (e) { | ||
console.log(e.stderr); | ||
if (e.message.includes("-1728")) { | ||
throw new Error(`Cannot delete non-existing group item with name ${groupName}`); | ||
} | ||
throw e; | ||
} | ||
@@ -124,3 +174,3 @@ } | ||
* Store metadata in the group name | ||
* @param name | ||
* @param {string?} name | ||
* @protected | ||
@@ -131,5 +181,7 @@ */ | ||
prefix: this.prefix, | ||
version: this.version | ||
version: this.version, | ||
lastIndex: this.lastIndex | ||
}; | ||
return `${name.padEnd(20, " ")} ${JSON.stringify(data)}`; | ||
name = name || this.name; | ||
return `${name.padEnd(50, " ")} ${JSON.stringify(data)}`; | ||
} | ||
@@ -156,2 +208,13 @@ /** | ||
/** | ||
* Removes the Bookends group containing the library items | ||
*/ | ||
async delete() { | ||
if (this.groupName) { | ||
await this.removeGroup(this.groupName); | ||
} | ||
else { | ||
throw new Error("Cannot delete Library - bookends group name has not been determined yet."); | ||
} | ||
} | ||
/** | ||
* Adds a Zotero collection object | ||
@@ -192,11 +255,11 @@ * @param {Zotero.Collection} collection | ||
/** | ||
* Returns the id of the item with the given citeky, or undefined if none such item exists | ||
* Returns the data of the publication item with the given citeky, or undefined if none such item exists | ||
* @param citekey | ||
* @protected | ||
*/ | ||
async getIdByCitekey(citekey) { | ||
async getPublicationByCitekey(citekey) { | ||
return await this.run(` | ||
const items = bookends.sqlSearch("user1 REGEX '${citekey}'", {in: libraryWindow}); | ||
if (items.length) { | ||
return items[0].id() | ||
return items[0].properties(); | ||
} | ||
@@ -206,2 +269,55 @@ return undefined;`); | ||
/** | ||
* Adds a publication item and links it to the current group | ||
* @param {object} data | ||
* @param {string?} groupName Optional name of the group, by default the group that contains the library items | ||
* @protected | ||
*/ | ||
async addPublication(data, groupName) { | ||
groupName = groupName || this.groupName; | ||
await this.run(` | ||
const item = bookends.PublicationItem(${JSON.stringify(data)}); | ||
libraryWindow.publicationItems.push(item); | ||
const group = libraryWindow.groupItems.byName(\`${groupName}\`); | ||
bookends.add(item, {to:group}); | ||
`); | ||
} | ||
/** | ||
* Updates a publication item | ||
* @param {number} id | ||
* @param {object} data | ||
* @protected | ||
*/ | ||
async updatePublication(id, data) { | ||
try { | ||
await this.run(` | ||
const item = libraryWindow.publicationItems.byId("${id}"); | ||
for (let [key, value] of Object.entries(args[0])) { | ||
item.setProperty(key, value); | ||
} | ||
`, [data]); | ||
} | ||
catch (e) { | ||
if (e.message.includes("-1728")) { | ||
throw new Error(`Cannot update non-existing publication item with id ${id}`); | ||
} | ||
throw e; | ||
} | ||
} | ||
/** | ||
* Deletes a publication item | ||
* @param id | ||
* @protected | ||
*/ | ||
async removePublication(id) { | ||
try { | ||
await this.run(`bookends.delete(libraryWindow.publicationItems.byId(${id})`); | ||
} | ||
catch (e) { | ||
if (e.message.includes("-1728")) { | ||
throw new Error(`Cannot delete non-existing publication item with id ${id}`); | ||
} | ||
throw e; | ||
} | ||
} | ||
/** | ||
* Adds or updates a Zotero item object | ||
@@ -211,2 +327,10 @@ * @param {Zotero.Item.Any} item | ||
async add(item) { | ||
// fast-forward in the case of a previous aborted sync | ||
if (this.fastForwardTo > 0 && this.lastIndex < this.fastForwardTo) { | ||
if (this.lastIndex === 0) { | ||
this.debug(`Fast-forwarding, skipping previously synchronized items...`); | ||
} | ||
this.lastIndex++; | ||
return; | ||
} | ||
const citekey = this.generateCitekey(item); | ||
@@ -218,20 +342,48 @@ switch (item.itemType) { | ||
default: { | ||
const id = await this.getIdByCitekey(citekey); | ||
const data = this.zoteroToBookends(item, citekey); | ||
if (id) { | ||
await this.run(` | ||
const item = libraryWindow.publicationItems.byId("${id}"); | ||
for (let [key, value] of Object.entries(args[0])) { | ||
item.setProperty(key, value); | ||
} | ||
`, [data]); | ||
let tries = 0; | ||
let error = null; | ||
while (tries++ < this.maxTries) { | ||
try { | ||
const storedData = await this.getPublicationByCitekey(citekey); | ||
const data = this.zoteroToBookends(item, citekey); | ||
if (storedData) { | ||
const changedProperties = Object.keys(data).filter(key => data[key] !== storedData[key]); | ||
if (changedProperties.length) { | ||
// update if item has changed | ||
const changedData = {}; | ||
changedProperties.forEach(key => changedData[key] = data[key]); | ||
this.debug(`Updating item '${data.title}', properties ${Object.keys(changedData).join(",")}`); | ||
await this.updatePublication(Number(storedData.id), changedData); | ||
} | ||
} | ||
else { | ||
this.debug(`Adding item '${data.title}' ...`); | ||
await this.addPublication(data); | ||
} | ||
// success! | ||
error = null; | ||
break; // break while-loop | ||
} | ||
catch (e) { | ||
if (e.message.includes("-1712")) { | ||
// timeout, try again | ||
error = e; | ||
continue; | ||
} | ||
// try to save metadata, including last index, ignoring any errors | ||
try { | ||
await this.saveMetadata(); | ||
} | ||
catch (e) { } | ||
throw e; | ||
} | ||
} | ||
else { | ||
await this.run(` | ||
const item = bookends.PublicationItem(${JSON.stringify(data)}); | ||
const group = libraryWindow.groupItems.byName(\`${this.groupName}\`); | ||
libraryWindow.publicationItems.push(item); | ||
bookends.add(item, {to:group}); | ||
`); | ||
if (error) { | ||
throw error; | ||
} | ||
this.lastIndex++; | ||
// save metadata every 10 items so that this doesn't slow things down too much | ||
if (this.lastIndex % 10 === 0) { | ||
await this.saveMetadata(); | ||
} | ||
} | ||
@@ -241,23 +393,42 @@ } | ||
/** | ||
* Removes an Zotero item object | ||
* Removes Zotero item objects | ||
* @param {string[]} keys | ||
*/ | ||
async remove(keys) { | ||
// to do | ||
if (this.isNew) { | ||
// nothing to delete | ||
return; | ||
} | ||
for (let key of keys) { | ||
const item = await this.getPublicationByCitekey(this.generateCitekey({ key })); | ||
if (item) { | ||
this.debug(`Deleting '${item.title}' ...`); | ||
await this.removePublication(Number(item.id)); | ||
} | ||
} | ||
} | ||
/** | ||
* Saves the Library | ||
* @param {String?} name Descriptive Name of the library | ||
* Saves the library metadata in the group name | ||
* @protected | ||
*/ | ||
async saveMetadata() { | ||
const oldGroupName = await this.findGroupNameByPrefix(); | ||
if (!oldGroupName) { | ||
throw new Error("Cannot find group for prefix " + this.prefix); | ||
} | ||
this.groupName = this.generateGroupName(); | ||
await this.renameGroup(oldGroupName, this.groupName); | ||
} | ||
/** | ||
* Saves the Library metadata at the end of the sync process | ||
* @param {String} name Descriptive Name of the library | ||
* @param {Number} version | ||
*/ | ||
async save(name, version) { | ||
this.name = name; | ||
this.name = name || "User Library"; | ||
this.version = version; | ||
const oldGroupName = await this.findGroupNameByPrefix(); | ||
this.groupName = this.generateGroupName(name); | ||
await this.run(` | ||
libraryWindow.groupItems.byName(\`${oldGroupName}\`) | ||
.setProperty("name",\`${this.groupName}\`);`); | ||
this.lastIndex = 0; | ||
await this.saveMetadata(); | ||
} | ||
} | ||
exports.Library = Library; |
264
src/index.ts
@@ -5,7 +5,12 @@ import runJxa from 'run-jxa'; | ||
const { Translator, zoteroDictionary, bookendsDictionary } = require('./dictionaries/translator'); | ||
const process = require('process'); | ||
export class Store implements Zotero.Store { | ||
// interface properties | ||
public libraries : string[]; | ||
// public properties | ||
public fileName: string; | ||
public static verbose: boolean = false; // set this to true if you want to have verbose output | ||
@@ -42,4 +47,2 @@ constructor(fileName: string) { | ||
/** | ||
@@ -54,2 +57,5 @@ * Implementation of a Zotero library object | ||
// public properties | ||
public maxTries = 3; // how often to retry an OSA command if it times out | ||
// internal config | ||
@@ -59,2 +65,6 @@ private readonly prefix: string; | ||
private groupName?: string; | ||
private fastForwardTo : number = 0; | ||
private lastIndex : number = 0; | ||
private isNew: boolean = false; | ||
private synchronizingMessage = "Synchronizing..."; | ||
@@ -67,2 +77,16 @@ constructor(store: Store, user_or_group_prefix: string) { | ||
/** | ||
* Output additional information on the console if the environment variable | ||
* ZOTERO_SYNC_DEBUG is set | ||
* @param {string} msg | ||
* @private | ||
*/ | ||
private debug(msg:string) { | ||
if (Store.verbose) { | ||
process.stdout.clearLine(); | ||
process.stdout.cursorTo(0); | ||
console.log(msg); | ||
} | ||
} | ||
/** | ||
* Runs a JXA script in the context of the current Bookends library. The following | ||
@@ -98,2 +122,3 @@ * constants are predefined when running the passed script fragement: | ||
this.groupName = await this.findGroupNameByPrefix(); | ||
this.lastIndex = 0; | ||
if (this.groupName) { | ||
@@ -103,5 +128,12 @@ const {name, data} = this.parseGroupName(this.groupName); | ||
this.version = data.version; | ||
if (this.version === 0) { | ||
// a case of an aborted initial sync | ||
this.isNew = true; | ||
} | ||
this.fastForwardTo = data.lastIndex || 0; | ||
} else { | ||
this.groupName = this.generateGroupName("Synchronizing..."); | ||
await this.run(`libraryWindow.groupItems.push(bookends.GroupItem({name:\`${this.groupName}\`}));`); | ||
this.isNew = true; | ||
this.name = this.synchronizingMessage; | ||
this.groupName = this.generateGroupName(); | ||
await this.addGroup(this.groupName); | ||
} | ||
@@ -112,9 +144,32 @@ return this; | ||
/** | ||
* Deletes the Bookends group containing the library items | ||
* Adds a Bookends group | ||
* @param groupName | ||
*/ | ||
public async delete() { | ||
protected async addGroup(groupName:string) { | ||
await this.run(`libraryWindow.groupItems.push(bookends.GroupItem({name:\`${groupName}\`}));`); | ||
} | ||
/** | ||
* Renames a Bookends group | ||
* @param oldGroupName | ||
* @param newGroupName | ||
* @protected | ||
*/ | ||
protected async renameGroup(oldGroupName: string, newGroupName: string) { | ||
await this.run(`libraryWindow.groupItems.byName(\`${oldGroupName}\`).setProperty("name",\`${newGroupName}\`);`); | ||
} | ||
/** | ||
* Deletes a Bookends group | ||
* @param groupName | ||
* @protected | ||
*/ | ||
protected async removeGroup(groupName:string) { | ||
try { | ||
await this.run(`bookends.delete(libraryWindows.groupItems.byName(\`${this.groupName}\`))`); | ||
} catch (e) { | ||
console.log(e.stderr); | ||
await this.run(`bookends.delete(libraryWindow.groupItems.byName(\`${groupName}\`))`); | ||
} catch(e) { | ||
if (e.message.includes("-1728")) { | ||
throw new Error(`Cannot delete non-existing group item with name ${groupName}`); | ||
} | ||
throw e; | ||
} | ||
@@ -127,3 +182,3 @@ } | ||
*/ | ||
protected parseGroupName(groupName: string): {name: string, data: {version:number, prefix:string}} { | ||
protected parseGroupName(groupName: string): {name: string, data: {version:number, prefix:string, lastIndex:number }} { | ||
if (!groupName) { | ||
@@ -140,11 +195,13 @@ throw new Error("Missing group name"); | ||
* Store metadata in the group name | ||
* @param name | ||
* @param {string?} name | ||
* @protected | ||
*/ | ||
protected generateGroupName(name: string): string { | ||
protected generateGroupName(name?: string): string { | ||
const data = { | ||
prefix: this.prefix, | ||
version: this.version | ||
version: this.version, | ||
lastIndex: this.lastIndex | ||
}; | ||
return `${name.padEnd(20, " ")} ${JSON.stringify(data)}`; | ||
name = name || this.name; | ||
return `${name.padEnd(50, " ")} ${JSON.stringify(data)}`; | ||
} | ||
@@ -173,2 +230,13 @@ | ||
/** | ||
* Removes the Bookends group containing the library items | ||
*/ | ||
public async delete() { | ||
if (this.groupName) { | ||
await this.removeGroup(this.groupName); | ||
} else { | ||
throw new Error("Cannot delete Library - bookends group name has not been determined yet."); | ||
} | ||
} | ||
/** | ||
* Adds a Zotero collection object | ||
@@ -196,3 +264,3 @@ * @param {Zotero.Collection} collection | ||
*/ | ||
protected generateCitekey(item: Zotero.Item.Any) : string { | ||
protected generateCitekey(item: Zotero.Item.Any|{key:string}) : string { | ||
return `https://api.zotero.org${this.prefix}/items/${item.key}`; | ||
@@ -207,3 +275,3 @@ } | ||
*/ | ||
protected zoteroToBookends(item: Zotero.Item.Any | any, citekey: string): object { | ||
protected zoteroToBookends(item: Zotero.Item.Any | any, citekey: string): { [key: string] : string} { | ||
const data = Translator.translate(item, zoteroDictionary, bookendsDictionary); | ||
@@ -215,11 +283,11 @@ data.citekey = citekey; | ||
/** | ||
* Returns the id of the item with the given citeky, or undefined if none such item exists | ||
* Returns the data of the publication item with the given citeky, or undefined if none such item exists | ||
* @param citekey | ||
* @protected | ||
*/ | ||
public async getIdByCitekey(citekey: string) : Promise<string|undefined> { | ||
protected async getPublicationByCitekey(citekey: string) : Promise<{[key:string]:string}|undefined> { | ||
return await this.run(` | ||
const items = bookends.sqlSearch("user1 REGEX '${citekey}'", {in: libraryWindow}); | ||
if (items.length) { | ||
return items[0].id() | ||
return items[0].properties(); | ||
} | ||
@@ -230,2 +298,56 @@ return undefined;`); | ||
/** | ||
* Adds a publication item and links it to the current group | ||
* @param {object} data | ||
* @param {string?} groupName Optional name of the group, by default the group that contains the library items | ||
* @protected | ||
*/ | ||
protected async addPublication(data: {[key:string]:string}, groupName?:string) : Promise<void> { | ||
groupName = groupName || this.groupName; | ||
await this.run(` | ||
const item = bookends.PublicationItem(${JSON.stringify(data)}); | ||
libraryWindow.publicationItems.push(item); | ||
const group = libraryWindow.groupItems.byName(\`${groupName}\`); | ||
bookends.add(item, {to:group}); | ||
`); | ||
} | ||
/** | ||
* Updates a publication item | ||
* @param {number} id | ||
* @param {object} data | ||
* @protected | ||
*/ | ||
protected async updatePublication(id: number, data: {[key:string]:string}) : Promise<void> { | ||
try { | ||
await this.run(` | ||
const item = libraryWindow.publicationItems.byId("${id}"); | ||
for (let [key, value] of Object.entries(args[0])) { | ||
item.setProperty(key, value); | ||
} | ||
`, [data]); | ||
} catch(e) { | ||
if (e.message.includes("-1728")) { | ||
throw new Error(`Cannot update non-existing publication item with id ${id}`); | ||
} | ||
throw e; | ||
} | ||
} | ||
/** | ||
* Deletes a publication item | ||
* @param id | ||
* @protected | ||
*/ | ||
protected async removePublication(id: number) : Promise<void> { | ||
try { | ||
await this.run(`bookends.delete(libraryWindow.publicationItems.byId(${id})`); | ||
} catch(e) { | ||
if (e.message.includes("-1728")) { | ||
throw new Error(`Cannot delete non-existing publication item with id ${id}`); | ||
} | ||
throw e; | ||
} | ||
} | ||
/** | ||
* Adds or updates a Zotero item object | ||
@@ -235,2 +357,10 @@ * @param {Zotero.Item.Any} item | ||
public async add(item: Zotero.Item.Any | any): Promise<void> { | ||
// fast-forward in the case of a previous aborted sync | ||
if (this.fastForwardTo > 0 && this.lastIndex < this.fastForwardTo) { | ||
if (this.lastIndex === 0) { | ||
this.debug(`Fast-forwarding, skipping previously synchronized items...`); | ||
} | ||
this.lastIndex++; | ||
return; | ||
} | ||
const citekey = this.generateCitekey(item); | ||
@@ -242,19 +372,45 @@ switch (item.itemType) { | ||
default: { | ||
const id = await this.getIdByCitekey(citekey); | ||
const data = this.zoteroToBookends(item, citekey); | ||
if (id) { | ||
await this.run(` | ||
const item = libraryWindow.publicationItems.byId("${id}"); | ||
for (let [key, value] of Object.entries(args[0])) { | ||
item.setProperty(key, value); | ||
let tries = 0; | ||
let error: null | string = null; | ||
while(tries++ < this.maxTries) { | ||
try { | ||
const storedData = await this.getPublicationByCitekey(citekey) | ||
const data = this.zoteroToBookends(item, citekey); | ||
if (storedData) { | ||
const changedProperties = Object.keys(data).filter( key => data[key] !== storedData[key]); | ||
if (changedProperties.length) { | ||
// update if item has changed | ||
const changedData: {[key: string]: string} = {}; | ||
changedProperties.forEach(key => changedData[key] = data[key]); | ||
this.debug(`Updating item '${data.title}', properties ${Object.keys(changedData).join(",")}`); | ||
await this.updatePublication(Number(storedData.id), changedData); | ||
} | ||
} else { | ||
this.debug(`Adding item '${data.title}' ...`); | ||
await this.addPublication(data); | ||
} | ||
// success! | ||
error = null; | ||
break; // break while-loop | ||
} catch (e) { | ||
if (e.message.includes("-1712")) { | ||
// timeout, try again | ||
error = e; | ||
continue; | ||
} | ||
// try to save metadata, including last index, ignoring any errors | ||
try { | ||
await this.saveMetadata(); | ||
} catch (e) {} | ||
throw e; | ||
} | ||
`, [data]); | ||
} else { | ||
await this.run(` | ||
const item = bookends.PublicationItem(${JSON.stringify(data)}); | ||
const group = libraryWindow.groupItems.byName(\`${this.groupName}\`); | ||
libraryWindow.publicationItems.push(item); | ||
bookends.add(item, {to:group}); | ||
`); | ||
} | ||
if (error) { | ||
throw error; | ||
} | ||
this.lastIndex++; | ||
// save metadata every 10 items so that this doesn't slow things down too much | ||
if (this.lastIndex % 10 === 0) { | ||
await this.saveMetadata(); | ||
} | ||
} | ||
@@ -265,23 +421,43 @@ } | ||
/** | ||
* Removes an Zotero item object | ||
* Removes Zotero item objects | ||
* @param {string[]} keys | ||
*/ | ||
public async remove(keys: string[]): Promise<void> { | ||
// to do | ||
if (this.isNew) { | ||
// nothing to delete | ||
return; | ||
} | ||
for (let key of keys) { | ||
const item = await this.getPublicationByCitekey(this.generateCitekey({key})); | ||
if (item) { | ||
this.debug(`Deleting '${item.title}' ...`); | ||
await this.removePublication(Number(item.id)); | ||
} | ||
} | ||
} | ||
/** | ||
* Saves the Library | ||
* @param {String?} name Descriptive Name of the library | ||
* Saves the library metadata in the group name | ||
* @protected | ||
*/ | ||
protected async saveMetadata() { | ||
const oldGroupName = await this.findGroupNameByPrefix(); | ||
if (!oldGroupName) { | ||
throw new Error("Cannot find group for prefix " + this.prefix); | ||
} | ||
this.groupName = this.generateGroupName(); | ||
await this.renameGroup(oldGroupName, this.groupName); | ||
} | ||
/** | ||
* Saves the Library metadata at the end of the sync process | ||
* @param {String} name Descriptive Name of the library | ||
* @param {Number} version | ||
*/ | ||
public async save(name: string, version: number): Promise<void> { | ||
this.name = name; | ||
this.name = name || "User Library"; | ||
this.version = version; | ||
const oldGroupName = await this.findGroupNameByPrefix(); | ||
this.groupName = this.generateGroupName(name); | ||
await this.run(` | ||
libraryWindow.groupItems.byName(\`${oldGroupName}\`) | ||
.setProperty("name",\`${this.groupName}\`);`); | ||
this.lastIndex = 0; | ||
await this.saveMetadata(); | ||
} | ||
} |
10
test.ts
@@ -20,16 +20,20 @@ import { Sync } from '@retorquere/zotero-sync/index' | ||
Store.verbose = true; | ||
// configure visual feedback | ||
const gauge = new Gauge; | ||
let libraryName:string=""; | ||
syncEngine.on(Sync.event.library, (library, index: number, total: number) => { | ||
let name = library.type === "group" ? library.name : "User library"; | ||
libraryName = name; | ||
gauge.show(`Saving library "${name}" (${index}/${total})`, index/total); | ||
}); | ||
syncEngine.on(Sync.event.remove, (type: string, objects: string[]) => { | ||
gauge.show(`Removing ${objects.length} ${type}`); | ||
gauge.show(`"${libraryName.slice(0,20)}": Removing ${objects.length} ${type}`); | ||
}); | ||
syncEngine.on(Sync.event.collection, (collection: Zotero.Collection, index: number, total: number) => { | ||
gauge.show(`Saving collection ${index}/${total}`, index/total); | ||
gauge.show(`"${libraryName.slice(0,20)}": Saving collection ${index}/${total}`, index/total); | ||
}); | ||
syncEngine.on(Sync.event.item, (item: Zotero.Item.Any, index: number, total: number) => { | ||
gauge.show(`Saving item ${index}/${total}`, index/total); | ||
gauge.show(`"${libraryName.slice(0,20)}": Saving item ${index}/${total}`, index/total); | ||
}); | ||
@@ -36,0 +40,0 @@ |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
78912
2282
65