343 lines
11 KiB
JavaScript
343 lines
11 KiB
JavaScript
/*
|
|
***** BEGIN LICENSE BLOCK *****
|
|
|
|
Copyright © 2020 Corporation for Digital Scholarship
|
|
Vienna, Virginia, USA
|
|
https://www.zotero.org
|
|
|
|
This file is part of Zotero.
|
|
|
|
Zotero is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
Zotero is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
***** END LICENSE BLOCK *****
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
Zotero.Annotations = new function () {
|
|
Zotero.defineProperty(this, 'ANNOTATION_POSITION_MAX_SIZE', { value: 65000 });
|
|
// Keep in sync with items.js::loadAnnotations()
|
|
Zotero.defineProperty(this, 'ANNOTATION_TYPE_HIGHLIGHT', { value: 1 });
|
|
Zotero.defineProperty(this, 'ANNOTATION_TYPE_NOTE', { value: 2 });
|
|
Zotero.defineProperty(this, 'ANNOTATION_TYPE_IMAGE', { value: 3 });
|
|
Zotero.defineProperty(this, 'ANNOTATION_TYPE_INK', { value: 4 });
|
|
|
|
Zotero.defineProperty(this, 'PROPS', {
|
|
value: ['type', 'authorName', 'text', 'comment', 'color', 'pageLabel', 'sortIndex', 'position'],
|
|
writable: false
|
|
});
|
|
|
|
|
|
this.getCacheImagePath = function ({ libraryID, key }) {
|
|
var file = this._getLibraryCacheDirectory(libraryID);
|
|
return OS.Path.join(file, key + '.png');
|
|
};
|
|
|
|
|
|
this.hasCacheImage = async function (item) {
|
|
return OS.File.exists(this.getCacheImagePath(item));
|
|
};
|
|
|
|
|
|
this.saveCacheImage = async function ({ libraryID, key }, blob) {
|
|
var item = await Zotero.Items.getByLibraryAndKeyAsync(libraryID, key);
|
|
if (!item) {
|
|
throw new Error(`Item not found`);
|
|
}
|
|
if (item.itemType != 'annotation' || !['image', 'ink'].includes(item.annotationType)) {
|
|
throw new Error("Item must be an image/ink annotation item");
|
|
}
|
|
|
|
var cacheDir = Zotero.DataDirectory.getSubdirectory('cache', true);
|
|
var file = this._getLibraryCacheDirectory(item.libraryID);
|
|
await Zotero.File.createDirectoryIfMissingAsync(file, { from: cacheDir });
|
|
|
|
file = OS.Path.join(file, item.key + '.png');
|
|
Zotero.debug("Creating annotation cache file " + file);
|
|
await Zotero.File.putContentsAsync(file, blob);
|
|
await Zotero.File.setNormalFilePermissions(file);
|
|
|
|
return file;
|
|
};
|
|
|
|
|
|
this.removeCacheImage = async function ({ libraryID, key }) {
|
|
var path = this.getCacheImagePath({ libraryID, key });
|
|
Zotero.debug("Deleting annotation cache file " + path);
|
|
await OS.File.remove(path, { ignoreAbsent: true });
|
|
};
|
|
|
|
|
|
/**
|
|
* Remove cache files that are no longer in use
|
|
*/
|
|
this.removeOrphanedCacheFiles = async function () {
|
|
// TODO
|
|
};
|
|
|
|
|
|
/**
|
|
* Remove all cache files for a given library
|
|
*/
|
|
this.removeLibraryCacheFiles = async function (libraryID) {
|
|
var path = this._getLibraryCacheDirectory(libraryID);
|
|
await OS.File.removeDir(path, { ignoreAbsent: true, ignorePermissions: true });
|
|
};
|
|
|
|
|
|
this._getLibraryCacheDirectory = function (libraryID) {
|
|
var parts = [Zotero.DataDirectory.getSubdirectory('cache')];
|
|
var library = Zotero.Libraries.get(libraryID);
|
|
if (library.libraryType == 'user') {
|
|
parts.push('library');
|
|
}
|
|
else if (library.libraryType == 'group') {
|
|
parts.push('groups', library.groupID + '');
|
|
}
|
|
else {
|
|
throw new Error(`Unexpected library type '${library.libraryType}'`);
|
|
}
|
|
return OS.Path.join(...parts);
|
|
};
|
|
|
|
|
|
this.toJSON = async function (item) {
|
|
var o = {};
|
|
o.libraryID = item.libraryID;
|
|
o.key = item.key;
|
|
o.type = item.annotationType;
|
|
o.isExternal = item.annotationIsExternal;
|
|
var isAuthor = !item.createdByUserID || item.createdByUserID == Zotero.Users.getCurrentUserID();
|
|
var isGroup = item.library.libraryType == 'group';
|
|
if (item.annotationAuthorName) {
|
|
o.authorName = item.annotationAuthorName;
|
|
if (isGroup) {
|
|
o.lastModifiedByUser = Zotero.Users.getName(item.lastModifiedByUserID)
|
|
|| Zotero.Users.getName(item.createdByUserID);
|
|
}
|
|
}
|
|
else if (!o.isExternal && isGroup) {
|
|
o.authorName = Zotero.Users.getName(item.createdByUserID);
|
|
o.isAuthorNameAuthoritative = true;
|
|
if (item.lastModifiedByUserID) {
|
|
o.lastModifiedByUser = Zotero.Users.getName(item.lastModifiedByUserID);
|
|
}
|
|
}
|
|
o.readOnly = o.isExternal || !isAuthor;
|
|
if (o.type == 'highlight') {
|
|
o.text = item.annotationText;
|
|
}
|
|
else if (['image', 'ink'].includes(o.type)) {
|
|
let file = this.getCacheImagePath(item);
|
|
if (await OS.File.exists(file)) {
|
|
o.image = await Zotero.File.generateDataURI(file, 'image/png');
|
|
}
|
|
}
|
|
o.comment = item.annotationComment;
|
|
o.pageLabel = item.annotationPageLabel;
|
|
o.color = item.annotationColor;
|
|
o.sortIndex = item.annotationSortIndex;
|
|
// annotationPosition is a JSON string, but we want to pass the raw object to the reader
|
|
o.position = JSON.parse(item.annotationPosition);
|
|
|
|
// Add tags and tag colors
|
|
var tagColors = Zotero.Tags.getColors(item.libraryID);
|
|
var tags = item.getTags().map((t) => {
|
|
let obj = {
|
|
name: t.tag
|
|
};
|
|
if (tagColors.has(t.tag)) {
|
|
obj.color = tagColors.get(t.tag).color;
|
|
// Add 'position' for sorting
|
|
obj.position = tagColors.get(t.tag).position;
|
|
}
|
|
return obj;
|
|
});
|
|
// Sort colored tags by position and other tags by name
|
|
tags.sort((a, b) => {
|
|
if (!a.color && !b.color) return Zotero.localeCompare(a.name, b.name);
|
|
if (!a.color && !b.color) return -1;
|
|
if (!a.color && b.color) return 1;
|
|
return a.position - b.position;
|
|
});
|
|
// Remove temporary 'position' value
|
|
tags.forEach(t => delete t.position);
|
|
if (tags.length) {
|
|
o.tags = tags;
|
|
}
|
|
|
|
o.dateModified = Zotero.Date.sqlToISO8601(item.dateModified);
|
|
return o;
|
|
};
|
|
|
|
|
|
/**
|
|
* @param {Zotero.Item} attachment - Saved parent attachment item
|
|
* @param {Object} json
|
|
* @return {Promise<Zotero.Item>} - Promise for an annotation item
|
|
*/
|
|
this.saveFromJSON = async function (attachment, json, saveOptions = {}) {
|
|
if (!attachment) {
|
|
throw new Error("'attachment' not provided");
|
|
}
|
|
if (!attachment.libraryID) {
|
|
throw new Error("'attachment' is not saved");
|
|
}
|
|
if (!json.key) {
|
|
throw new Error("'key' not provided in JSON");
|
|
}
|
|
|
|
var item = Zotero.Items.getByLibraryAndKey(attachment.libraryID, json.key);
|
|
if (!item) {
|
|
item = new Zotero.Item('annotation');
|
|
item.libraryID = attachment.libraryID;
|
|
item.key = json.key;
|
|
await item.loadPrimaryData();
|
|
}
|
|
item.parentID = attachment.id;
|
|
|
|
item._requireData('annotation');
|
|
item._requireData('annotationDeferred');
|
|
item.annotationType = json.type;
|
|
item.annotationAuthorName = json.authorName || '';
|
|
if (json.type == 'highlight') {
|
|
item.annotationText = json.text;
|
|
}
|
|
item.annotationIsExternal = !!json.isExternal;
|
|
item.annotationComment = json.comment;
|
|
item.annotationColor = json.color;
|
|
item.annotationPageLabel = json.pageLabel;
|
|
item.annotationSortIndex = json.sortIndex;
|
|
|
|
item.annotationPosition = JSON.stringify(Object.assign({}, json.position));
|
|
// TODO: Can colors be set?
|
|
item.setTags((json.tags || []).map(t => ({ tag: t.name })));
|
|
|
|
// For Mendeley import -- additive only
|
|
if (json.relations) {
|
|
for (let predicate in json.relations) {
|
|
item.addRelation(predicate, json.relations[predicate]);
|
|
}
|
|
}
|
|
|
|
await item.saveTx(saveOptions);
|
|
|
|
return item;
|
|
};
|
|
|
|
/**
|
|
* Split annotation if position exceed the limit
|
|
*
|
|
* @param {Object} annotation
|
|
* @returns {Array<Object>} annotations
|
|
*/
|
|
this.splitAnnotationJSON = function (annotation) {
|
|
let splitAnnotations = [];
|
|
let tmpAnnotation = null;
|
|
let totalLength = 0;
|
|
if (annotation.position.rects) {
|
|
for (let i = 0; i < annotation.position.rects.length; i++) {
|
|
let rect = annotation.position.rects[i];
|
|
if (!tmpAnnotation) {
|
|
tmpAnnotation = JSON.parse(JSON.stringify(annotation));
|
|
tmpAnnotation.key = Zotero.DataObjectUtilities.generateKey();
|
|
tmpAnnotation.position.rects = [];
|
|
totalLength = JSON.stringify(tmpAnnotation.position).length;
|
|
}
|
|
// [],
|
|
let length = rect.join(',').length + 3;
|
|
if (totalLength + length <= this.ANNOTATION_POSITION_MAX_SIZE) {
|
|
tmpAnnotation.position.rects.push(rect);
|
|
totalLength += length;
|
|
}
|
|
else if (!tmpAnnotation.position.rects.length) {
|
|
throw new Error(`Cannot fit single 'rect' into 'position'`);
|
|
}
|
|
else {
|
|
splitAnnotations.push(tmpAnnotation);
|
|
tmpAnnotation = null;
|
|
i--;
|
|
}
|
|
}
|
|
if (tmpAnnotation) {
|
|
splitAnnotations.push(tmpAnnotation);
|
|
}
|
|
}
|
|
else if (annotation.position.paths) {
|
|
for (let i = 0; i < annotation.position.paths.length; i++) {
|
|
let path = annotation.position.paths[i];
|
|
for (let j = 0; j < path.length; j += 2) {
|
|
if (!tmpAnnotation) {
|
|
tmpAnnotation = JSON.parse(JSON.stringify(annotation));
|
|
tmpAnnotation.key = Zotero.DataObjectUtilities.generateKey();
|
|
tmpAnnotation.position.paths = [[]];
|
|
totalLength = JSON.stringify(tmpAnnotation.position).length;
|
|
}
|
|
let point = [path[j], path[j + 1]];
|
|
// 1,2,
|
|
let length = point.join(',').length + 1;
|
|
if (totalLength + length <= this.ANNOTATION_POSITION_MAX_SIZE) {
|
|
tmpAnnotation.position.paths[tmpAnnotation.position.paths.length - 1].push(...point);
|
|
totalLength += length;
|
|
}
|
|
else if (tmpAnnotation.position.paths.length === 1
|
|
&& !tmpAnnotation.position.paths[tmpAnnotation.position.paths.length - 1].length) {
|
|
throw new Error(`Cannot fit single point into 'position'`);
|
|
}
|
|
else {
|
|
splitAnnotations.push(tmpAnnotation);
|
|
tmpAnnotation = null;
|
|
j -= 2;
|
|
}
|
|
}
|
|
// If not the last path
|
|
if (i !== annotation.position.paths.length - 1) {
|
|
// [],
|
|
totalLength += 3;
|
|
tmpAnnotation.position.paths.push([]);
|
|
}
|
|
}
|
|
if (tmpAnnotation) {
|
|
splitAnnotations.push(tmpAnnotation);
|
|
}
|
|
}
|
|
return splitAnnotations;
|
|
};
|
|
|
|
/**
|
|
* Split annotations
|
|
*
|
|
* @param {Zotero.Item[]} items
|
|
* @returns {Promise<void>}
|
|
*/
|
|
this.splitAnnotations = async function (items) {
|
|
if (!Array.isArray(items)) {
|
|
items = [items];
|
|
}
|
|
if (!items.every(item => item.isAnnotation())) {
|
|
throw new Error('All items must be annotations');
|
|
}
|
|
for (let item of items) {
|
|
if (item.annotationPosition.length <= this.ANNOTATION_POSITION_MAX_SIZE) {
|
|
continue;
|
|
}
|
|
let annotation = await this.toJSON(item);
|
|
let splitAnnotations = this.splitAnnotationJSON(annotation);
|
|
for (let splitAnnotation of splitAnnotations) {
|
|
await this.saveFromJSON(item.parentItem, splitAnnotation);
|
|
}
|
|
await item.eraseTx();
|
|
}
|
|
};
|
|
};
|