getMessage(), "Incorrect datetime value") !== false) {
+ preg_match("/Incorrect datetime value: '([^']+)'/", $e->getMessage(), $matches);
+ throw new Exception("=Invalid date value '{$matches[1]}' for item $key", Z_ERROR_INVALID_INPUT);
+ }
+ throw $e;
+ }
+ if (!$this->_id) {
+ if (!$insertID) {
+ throw new Exception("Item id not available after INSERT");
+ }
+ $itemID = $insertID;
+ $this->_serverDateModified = $timestamp;
+ }
+
+ $createdByUserID = $userID;
+
+ // Remove from delete log if present, and if group item restore the previous
+ // createdByUserID (e.g., in case another user is doing a Replace Online Library
+ // or choosing the local version for conflict resolution)
+ $deleteFromLog = true;
+ if ($isGroupLibrary) {
+ $sql = "SELECT version, data FROM syncDeleteLogKeys "
+ . "WHERE libraryID=? AND objectType='item' AND `key`=?";
+ $row = Zotero_DB::rowQuery($sql, [$this->_libraryID, $key], $shardID);
+ if ($row) {
+ $data = json_decode($row['data']);
+ if (!empty($data->createdByUserID)) {
+ $createdByUserID = $data->createdByUserID;
+ }
+ }
+ else {
+ $deleteFromLog = false;
+ }
+ }
+ if ($deleteFromLog) {
+ $sql = "DELETE FROM syncDeleteLogKeys "
+ . "WHERE libraryID=? AND objectType='item' AND `key`=?";
+ Zotero_DB::query($sql, [$this->_libraryID, $key], $shardID);
+ }
+
+ // Group item data
+ if ($isGroupLibrary && $createdByUserID) {
+ $sql = "INSERT INTO groupItems VALUES (?, ?, ?)";
+ Zotero_DB::query($sql, [$itemID, $createdByUserID, $userID], $shardID);
+ }
+
+ //
+ // ItemData
+ //
+ if (!empty($this->changed['itemData'])) {
+ // Use manual bound parameters to speed things up
+ $origInsertSQL = "INSERT INTO itemData (itemID, fieldID, value) VALUES ";
+ $insertSQL = $origInsertSQL;
+ $insertParams = array();
+ $insertCounter = 0;
+ $maxInsertGroups = 40;
+
+ $max = Zotero_Items::$maxDataValueLength;
+
+ $fieldIDs = array_keys($this->changed['itemData']);
+
+ foreach ($fieldIDs as $fieldID) {
+ $value = $this->getField($fieldID, true, false, true);
+
+ if ($value == 'CURRENT_TIMESTAMP'
+ && Zotero_ItemFields::getID('accessDate') == $fieldID) {
+ $value = Zotero_DB::getTransactionTimestamp();
+ }
+
+ // Check length
+ if (strlen($value) > $max) {
+ $fieldName = Zotero_ItemFields::getLocalizedString($fieldID);
+ $msg = "=$fieldName field value " .
+ "'" . mb_substr($value, 0, 50) . "…' too long";
+ if ($this->_key) {
+ $msg .= " for item '" . $this->_libraryID . "/" . $key . "'";
+ }
+ throw new Exception($msg, Z_ERROR_FIELD_TOO_LONG);
+ }
+
+ if ($insertCounter < $maxInsertGroups) {
+ $insertSQL .= "(?,?,?),";
+ $insertParams = array_merge(
+ $insertParams,
+ array($itemID, $fieldID, $value)
+ );
+ }
+
+ if ($insertCounter == $maxInsertGroups - 1) {
+ $insertSQL = substr($insertSQL, 0, -1);
+ $stmt = Zotero_DB::getStatement($insertSQL, true, $shardID);
+ Zotero_DB::queryFromStatement($stmt, $insertParams);
+ $insertSQL = $origInsertSQL;
+ $insertParams = array();
+ $insertCounter = -1;
+ }
+
+ $insertCounter++;
+ }
+
+ if ($insertCounter > 0 && $insertCounter < $maxInsertGroups) {
+ $insertSQL = substr($insertSQL, 0, -1);
+ $stmt = Zotero_DB::getStatement($insertSQL, true, $shardID);
+ Zotero_DB::queryFromStatement($stmt, $insertParams);
+ }
+ }
+
+ //
+ // Creators
+ //
+ if (!empty($this->changed['creators'])) {
+ $indexes = array_keys($this->changed['creators']);
+
+ // TODO: group queries
+
+ $sql = "INSERT INTO itemCreators
+ (itemID, creatorID, creatorTypeID, orderIndex) VALUES ";
+ $placeholders = array();
+ $sqlValues = array();
+
+ $cacheRows = array();
+
+ foreach ($indexes as $orderIndex) {
+ Z_Core::debug('Adding creator in position ' . $orderIndex, 4);
+ $creator = $this->getCreator($orderIndex);
+
+ if (!$creator) {
+ continue;
+ }
+
+ if ($creator['ref']->hasChanged()) {
+ Z_Core::debug("Auto-saving changed creator {$creator['ref']->id}");
+ try {
+ $creator['ref']->save();
+ }
+ catch (Exception $e) {
+ // TODO: Provide the item in question
+ /*if (strpos($e->getCode() == Z_ERROR_CREATOR_TOO_LONG)) {
+ $msg = $e->getMessage();
+ $msg = str_replace(
+ "with this name and shorten it.",
+ "with this name, or paste '$key' into the quick search bar "
+ . "in the Zotero toolbar, and shorten the name."
+ );
+ throw new Exception($msg, Z_ERROR_CREATOR_TOO_LONG);
+ }*/
+ throw $e;
+ }
+ }
+
+ $placeholders[] = "(?, ?, ?, ?)";
+ array_push(
+ $sqlValues,
+ $itemID,
+ $creator['ref']->id,
+ $creator['creatorTypeID'],
+ $orderIndex
+ );
+
+ $cacheRows[] = array(
+ 'creatorID' => $creator['ref']->id,
+ 'creatorTypeID' => $creator['creatorTypeID'],
+ 'orderIndex' => $orderIndex
+ );
+ }
+
+ if ($sqlValues) {
+ $sql = $sql . implode(',', $placeholders);
+ Zotero_DB::query($sql, $sqlValues, $shardID);
+ }
+ }
+
+
+ // Deleted item
+ if (!empty($this->changed['deleted'])) {
+ if ($this->_deleted) {
+ $sql = "REPLACE INTO deletedItems (itemID) VALUES (?)";
+ }
+ else {
+ $sql = "DELETE FROM deletedItems WHERE itemID=?";
+ }
+ Zotero_DB::query($sql, $itemID, $shardID);
+ }
+
+
+ // My Publications item
+ if (!empty($this->changed['inPublications'])) {
+ if ($this->getPublications()) {
+ $sql = "INSERT IGNORE INTO publicationsItems (itemID) VALUES (?)";
+ }
+ else {
+ $sql = "DELETE FROM publicationsItems WHERE itemID=?";
+ }
+ Zotero_DB::query($sql, $itemID, $shardID);
+ Zotero_Notifier::trigger('modify', 'publications', $this->libraryID);
+ }
+
+
+ // Note
+ if ($this->isNote() || !empty($this->changed['note'])) {
+ if (!$this->isNote() && !$this->isAttachment()) {
+ throw new Exception("Only notes and attachments can have notes");
+ }
+ if ($this->isEmbeddedImageAttachment()) {
+ throw new Exception("Embedded image attachments cannot have notes");
+ }
+
+ if (!is_string($this->noteText)) {
+ $this->noteText = '';
+ }
+ // If we don't have a sanitized note, generate one
+ if (is_null($this->noteTextSanitized)) {
+ $noteTextSanitized = Zotero_Notes::sanitize($this->noteText);
+
+ // But if note is sanitized already, store empty string
+ if ($this->noteText === $noteTextSanitized) {
+ $this->noteTextSanitized = '';
+ }
+ else {
+ $this->noteTextSanitized = $noteTextSanitized;
+ }
+ }
+
+ $this->noteTitle = Zotero_Notes::noteToTitle(
+ $this->noteTextSanitized === '' ? $this->noteText : $this->noteTextSanitized
+ );
+
+ $sql = "INSERT INTO itemNotes
+ (itemID, sourceItemID, note, noteSanitized, title, hash)
+ VALUES (?,?,?,?,?,?)";
+ $parent = $this->isNote() ? $this->getSource() : null;
+ if ($parent) {
+ $parentItem = Zotero_Items::get($this->_libraryID, $parent);
+ if (!$parentItem) {
+ throw new Exception("Parent item $parent not found");
+ }
+ if (!$parentItem->isRegularItem()) {
+ throw new Exception(
+ // Keep in sync with Errors.inc.php
+ "Parent item $this->_libraryID/$parentItem->key cannot be a note or attachment",
+ Z_ERROR_INVALID_ITEM_PARENT
+ );
+ }
+ }
+
+ $hash = $this->noteText ? md5($this->noteText) : '';
+ $bindParams = array(
+ $itemID,
+ $parent ? $parent : null,
+ $this->noteText !== null ? $this->noteText : '',
+ $this->noteTextSanitized,
+ $this->noteTitle,
+ $hash
+ );
+
+ try {
+ Zotero_DB::query($sql, $bindParams, $shardID);
+ }
+ catch (Exception $e) {
+ if (strpos($e->getMessage(), "Incorrect string value") !== false) {
+ throw new Exception("=Invalid character in note '" . Zotero_Utilities::ellipsize($this->noteTitle, 70) . "'", Z_ERROR_INVALID_INPUT);
+ }
+ throw ($e);
+ }
+ Zotero_Notes::updateNoteCache($this->_libraryID, $itemID, $this->noteText);
+ Zotero_Notes::updateHash($this->_libraryID, $itemID, $hash);
+ }
+
+
+ // Attachment
+ if ($this->isAttachment()) {
+ $sql = "INSERT INTO itemAttachments
+ (itemID, sourceItemID, linkMode, mimeType, charsetID, path, storageModTime, storageHash)
+ VALUES (?,?,?,?,?,?,?,?)";
+ $isEmbeddedImage = $this->attachmentLinkMode == 'embedded_image';
+
+ $parent = $this->getSource();
+ if ($parent) {
+ $parentItem = Zotero_Items::get($this->_libraryID, $parent);
+ if (!$parentItem) {
+ throw new Exception("Parent item $parent not found");
+ }
+ $parentKey = $parentItem->key;
+ // Don't allow item to be set as its own parent
+ if ($parentKey == $this->_key) {
+ // Keep in sync with Zotero_Errors::parseException
+ throw new Exception(
+ "Item $this->_libraryID/$this->key cannot be a child of itself",
+ Z_ERROR_ITEM_PARENT_SET_TO_SELF
+ );
+ }
+ if ($parentItem->getSource()) {
+ // Only embedded-image attachments can have child items as parents
+ if (!$isEmbeddedImage) {
+ throw new Exception("=Parent item $parentKey cannot be a child item", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ // Parent item must be a regular item, or, if this is an embedded image, a
+ // note
+ if (!($parentItem->isRegularItem()
+ || ($isEmbeddedImage && $parentItem->isNote()))) {
+ throw new Exception(
+ // Keep in sync with Errors.inc.php
+ "Parent item $this->_libraryID/$parentItem->key cannot be a note or attachment",
+ Z_ERROR_INVALID_ITEM_PARENT
+ );
+ }
+ }
+ else if ($isEmbeddedImage) {
+ throw new Exception("Embedded-image attachment must have a parent item", Z_ERROR_INVALID_INPUT);
+ }
+
+ $contentType = $this->attachmentContentType;
+ if ($isEmbeddedImage && strpos($contentType, 'image/') !== 0) {
+ throw new Exception("Embedded-image attachment must have an image content type", Z_ERROR_INVALID_INPUT);
+ }
+
+ $linkMode = Zotero_Attachments::linkModeNameToNumber($this->attachmentLinkMode);
+ $charsetID = Zotero_CharacterSets::getID($this->attachmentCharset);
+ $path = $this->attachmentPath;
+ $storageModTime = $this->attachmentStorageModTime;
+ $storageHash = $this->attachmentStorageHash;
+
+ $bindParams = array(
+ $itemID,
+ $parent ? $parent : null,
+ $linkMode + 1,
+ $this->attachmentMIMEType,
+ $charsetID ? $charsetID : null,
+ $path ? $path : '',
+ $storageModTime ? $storageModTime : null,
+ $storageHash ? $storageHash : null
+ );
+ Zotero_DB::query($sql, $bindParams, $shardID);
+ }
+
+ // Annotation
+ if ($this->isAnnotation()) {
+ $parent = $this->getSource();
+ if (!$parent) {
+ throw new Exception("Annotation item must have a parent item", Z_ERROR_INVALID_INPUT);
+ }
+ $parentItem = Zotero_Items::get($this->_libraryID, $parent);
+ if (!$parentItem) {
+ throw new Exception("Parent item $this->_libraryID/$parent not found");
+ }
+ if (!$parentItem->isFileAttachment()) {
+ throw new Exception(
+ "Parent item $parentItem->libraryKey of annotation must be a file attachment",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ if ($parentItem->attachmentContentType != 'application/pdf') {
+ throw new Exception(
+ "Parent item $parentItem->libraryKey of annotation must be a PDF",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ if (!empty($this->annotationText) && $this->annotationType != 'highlight') {
+ throw new Exception(
+ "'annotationText' can only be set for highlight annotations",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+
+ // Default color to yellow if not specified
+ if (!$this->annotationColor) {
+ $this->annotationColor = Zotero_Items::$defaultAnnotationColor;
+ }
+
+ $color = $this->annotationColor;
+ if ($color) {
+ // Strip '#' from hex color
+ if (!preg_match('/^#[0-9a-f]{6}$/', $color)) {
+ trigger_error("Invalid annotationColor", E_USER_ERROR);
+ }
+ $color = substr($color, 1);
+ }
+
+ $sql = "INSERT INTO itemAnnotations "
+ . "(itemID, parentItemID, `type`, authorName, text, comment, color, pageLabel, sortIndex, position) "
+ . "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+ $params = [
+ $itemID,
+ $parent,
+ $this->annotationType,
+ $this->annotationAuthorName,
+ $this->annotationText,
+ $this->annotationComment,
+ $color,
+ $this->annotationPageLabel,
+ $this->annotationSortIndex,
+ $this->annotationPosition,
+ ];
+ Zotero_DB::query($sql, $params, $shardID);
+ }
+
+ // Sort fields
+ $sortTitle = Zotero_Items::getSortTitle($this->getDisplayTitle(true));
+ $title = $this->getField('title', false, true);
+ if (mb_substr($sortTitle ?? '', 0, 5) == mb_substr($title ?? '', 0, 5)) {
+ $sortTitle = null;
+ }
+ $creatorSummary = $this->isRegularItem()
+ ? mb_strcut($this->getCreatorSummary(true), 0, Zotero_Creators::$creatorSummarySortLength)
+ : '';
+ $sql = "INSERT INTO itemSortFields (itemID, sortTitle, creatorSummary) VALUES (?, ?, ?)";
+ Zotero_DB::query($sql, array($itemID, $sortTitle, $creatorSummary), $shardID);
+
+ //
+ // Source item id
+ //
+ if ($sourceItemID = $this->getSource()) {
+ $newSourceItem = Zotero_Items::get($this->_libraryID, $sourceItemID);
+ if (!$newSourceItem) {
+ throw new Exception("Cannot set source to invalid item");
+ }
+
+ switch (Zotero_ItemTypes::getName($this->_itemTypeID)) {
+ case 'note':
+ $newSourceItem->incrementNoteCount();
+ break;
+ case 'attachment':
+ $newSourceItem->incrementAttachmentCount();
+ break;
+ case 'annotation':
+ $newSourceItem->incrementAnnotationCount();
+ break;
+ }
+
+ // Set the top-level item id, which is used in searches
+ $topLevelItemID = $sourceItemID;
+ $topLevelItem = $newSourceItem;
+ while ($nextID = $topLevelItem->getSource()) {
+ $topLevelItemID = $nextID;
+ $topLevelItem = Zotero_Items::get($this->_libraryID, $topLevelItemID);
+ }
+ Zotero_Items::setTopLevelItem([$itemID], $topLevelItemID, $shardID);
+ }
+
+ // Collections
+ if (!empty($this->changed['collections'])) {
+ if ($this->isEmbeddedImageAttachment()) {
+ throw new Exception("Embedded image attachments cannot be assigned to collections");
+ }
+
+ foreach ($this->collections as $collectionKey) {
+ $collection = Zotero_Collections::getByLibraryAndKey($this->_libraryID, $collectionKey);
+ if (!$collection) {
+ throw new Exception(
+ "Collection $this->_libraryID/$collectionKey doesn't exist",
+ Z_ERROR_COLLECTION_NOT_FOUND
+ );
+ }
+ $collection->addItem($itemID);
+ }
+ }
+
+ // Tags
+ if (!empty($this->changed['tags'])) {
+ if ($this->isEmbeddedImageAttachment()) {
+ throw new Exception("Embedded image attachments cannot have tags");
+ }
+
+ foreach ($this->tags as $tag) {
+ $tagID = Zotero_Tags::getID($this->libraryID, $tag->name, $tag->type);
+ if ($tagID) {
+ $tagObj = Zotero_Tags::get($this->_libraryID, $tagID);
+ }
+ else {
+ $tagObj = new Zotero_Tag;
+ $tagObj->libraryID = $this->_libraryID;
+ $tagObj->name = $tag->name;
+ $tagObj->type = (int) $tag->type ? $tag->type : 0;
+ }
+ $tagObj->addItem($this->_key);
+ $tagObj->save();
+ }
+ }
+
+ // Related items
+ if (!empty($this->changed['relations'])) {
+ if ($this->isEmbeddedImageAttachment()) {
+ throw new Exception("Embedded image attachments cannot have relations");
+ }
+
+ $uri = Zotero_URI::getItemURI($this);
+
+ $sql = "INSERT IGNORE INTO relations "
+ . "(relationID, libraryID, `key`, subject, predicate, object) "
+ . "VALUES (?, ?, ?, ?, ?, ?)";
+ $insertStatement = Zotero_DB::getStatement($sql, false, $shardID);
+ foreach ($this->relations as $rel) {
+ $insertStatement->execute(
+ array(
+ Zotero_ID::get('relations'),
+ $this->_libraryID,
+ Zotero_Relations::makeKey($uri, $rel[0], $rel[1]),
+ $uri,
+ $rel[0],
+ $rel[1]
+ )
+ );
+ }
+ }
+ }
+
+ //
+ // Existing item, update
+ //
+ else {
+ Z_Core::debug('Updating database with new item data for item '
+ . $this->_libraryID . '/' . $this->_key, 4);
+
+ $isNew = $env['isNew'] = false;
+
+ //
+ // Primary fields
+ //
+ $sql = "UPDATE items SET ";
+ $sqlValues = array();
+
+ $timestamp = Zotero_DB::getTransactionTimestamp();
+ $version = Zotero_Libraries::getUpdatedVersion($this->_libraryID);
+
+ $updateFields = array(
+ 'itemTypeID',
+ 'libraryID',
+ 'key',
+ 'dateAdded',
+ 'dateModified'
+ );
+
+ if (!empty($this->changed['primaryData'])) {
+ foreach ($updateFields as $updateField) {
+ if (in_array($updateField, $this->changed['primaryData'])) {
+ $sql .= "`$updateField`=?, ";
+ $sqlValues[] = $this->{"_$updateField"};
+ }
+ }
+ }
+
+ $sql .= "serverDateModified=?, version=? WHERE itemID=?";
+ array_push(
+ $sqlValues,
+ $timestamp,
+ $version,
+ $this->_id
+ );
+
+ Zotero_DB::query($sql, $sqlValues, $shardID);
+
+ $this->_serverDateModified = $timestamp;
+
+ // Group item data
+ if ($isGroupLibrary && $userID) {
+ $sql = "INSERT INTO groupItems VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE lastModifiedByUserID=?";
+ Zotero_DB::query($sql, array($this->_id, null, $userID, $userID), $shardID);
+ }
+
+
+ //
+ // ItemData
+ //
+ if (!empty($this->changed['itemData'])) {
+ $del = array();
+
+ $origReplaceSQL = "REPLACE INTO itemData (itemID, fieldID, value) VALUES ";
+ $replaceSQL = $origReplaceSQL;
+ $replaceParams = array();
+ $replaceCounter = 0;
+ $maxReplaceGroups = 40;
+
+ $max = Zotero_Items::$maxDataValueLength;
+
+ $fieldIDs = array_keys($this->changed['itemData']);
+
+ foreach ($fieldIDs as $fieldID) {
+ $value = $this->getField($fieldID, true, false, true);
+
+ // If field changed and is empty, mark row for deletion
+ if ($value === "") {
+ $del[] = $fieldID;
+ continue;
+ }
+
+ if ($value == 'CURRENT_TIMESTAMP'
+ && Zotero_ItemFields::getID('accessDate') == $fieldID) {
+ $value = Zotero_DB::getTransactionTimestamp();
+ }
+
+ // Check length
+ if (strlen($value) > $max) {
+ $fieldName = Zotero_ItemFields::getLocalizedString($fieldID);
+ $msg = "=$fieldName field value " .
+ "'" . mb_substr($value, 0, 50) . "...' too long";
+ if ($this->_key) {
+ $msg .= " for item '" . $this->_libraryID
+ . "/" . $this->_key . "'";
+ }
+ throw new Exception($msg, Z_ERROR_FIELD_TOO_LONG);
+ }
+
+ if ($replaceCounter < $maxReplaceGroups) {
+ $replaceSQL .= "(?,?,?),";
+ $replaceParams = array_merge($replaceParams,
+ array($this->_id, $fieldID, $value)
+ );
+ }
+
+ if ($replaceCounter == $maxReplaceGroups - 1) {
+ $replaceSQL = substr($replaceSQL, 0, -1);
+ $stmt = Zotero_DB::getStatement($replaceSQL, true, $shardID);
+ Zotero_DB::queryFromStatement($stmt, $replaceParams);
+ $replaceSQL = $origReplaceSQL;
+ $replaceParams = array();
+ $replaceCounter = -1;
+ }
+ $replaceCounter++;
+ }
+
+ if ($replaceCounter > 0 && $replaceCounter < $maxReplaceGroups) {
+ $replaceSQL = substr($replaceSQL, 0, -1);
+ $stmt = Zotero_DB::getStatement($replaceSQL, true, $shardID);
+ Zotero_DB::queryFromStatement($stmt, $replaceParams);
+ }
+
+ // Update memcached with used fields
+ $fids = array();
+ foreach ($this->itemData as $fieldID=>$value) {
+ if ($value !== false && $value !== null) {
+ $fids[] = $fieldID;
+ }
+ }
+
+ // Delete blank fields
+ if ($del) {
+ $sql = 'DELETE from itemData WHERE itemID=? AND fieldID IN (';
+ $sqlParams = array($this->_id);
+ foreach ($del as $d) {
+ $sql .= '?, ';
+ $sqlParams[] = $d;
+ }
+ $sql = substr($sql, 0, -2) . ')';
+
+ Zotero_DB::query($sql, $sqlParams, $shardID);
+ }
+ }
+
+ //
+ // Creators
+ //
+ if (!empty($this->changed['creators'])) {
+ $indexes = array_keys($this->changed['creators']);
+
+ $sql = "INSERT INTO itemCreators
+ (itemID, creatorID, creatorTypeID, orderIndex) VALUES ";
+ $placeholders = array();
+ $sqlValues = array();
+
+ $cacheRows = array();
+
+ foreach ($indexes as $orderIndex) {
+ Z_Core::debug('Creator in position ' . $orderIndex . ' has changed', 4);
+ $creator = $this->getCreator($orderIndex);
+
+ $sql2 = 'DELETE FROM itemCreators WHERE itemID=? AND orderIndex=?';
+ Zotero_DB::query($sql2, array($this->_id, $orderIndex), $shardID);
+
+ if (!$creator) {
+ continue;
+ }
+
+ if ($creator['ref']->hasChanged()) {
+ Z_Core::debug("Auto-saving changed creator {$creator['ref']->id}");
+ $creator['ref']->save();
+ }
+
+
+ $placeholders[] = "(?, ?, ?, ?)";
+ array_push(
+ $sqlValues,
+ $this->_id,
+ $creator['ref']->id,
+ $creator['creatorTypeID'],
+ $orderIndex
+ );
+ }
+
+ if ($sqlValues) {
+ $sql = $sql . implode(',', $placeholders);
+ Zotero_DB::query($sql, $sqlValues, $shardID);
+ }
+ }
+
+ // Deleted item
+ if (!empty($this->changed['deleted'])) {
+ $deleted = $this->getDeleted();
+ if ($deleted) {
+ $sql = "REPLACE INTO deletedItems (itemID) VALUES (?)";
+ }
+ else {
+ $sql = "DELETE FROM deletedItems WHERE itemID=?";
+ }
+ Zotero_DB::query($sql, $this->_id, $shardID);
+ }
+
+ // My Publications item
+ if (!empty($this->changed['inPublications'])) {
+ if ($this->getPublications()) {
+ $sql = "INSERT IGNORE INTO publicationsItems (itemID) VALUES (?)";
+ }
+ else {
+ $sql = "DELETE FROM publicationsItems WHERE itemID=?";
+ }
+ Zotero_DB::query($sql, $this->_id, $shardID);
+ Zotero_Notifier::trigger('modify', 'publications', $this->libraryID);
+ }
+
+
+ // Changing parent
+ if (!empty($this->changed['source'])) {
+ $parent = $this->getSource();
+
+ // In case this was previously a standalone item, delete from any collections
+ // it may have been in
+ $sql = "DELETE FROM collectionItems WHERE itemID=?";
+ Zotero_DB::query($sql, $this->_id, $shardID);
+
+ // Verify annotation parent change
+ if ($this->isAnnotation()) {
+ if (!$parent) {
+ throw new Exception(
+ "Annotation must have a parent item",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ $parentItem = Zotero_Items::get($this->_libraryID, $parent);
+ if (!$parentItem) {
+ throw new Exception(
+ "Parent item $parent not found",
+ Z_ERROR_ITEM_NOT_FOUND
+ );
+ }
+ if (!$parentItem->isPDFAttachment()) {
+ throw new Exception(
+ "Parent item of annotation must be a PDF attachment",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ }
+
+ // Don't allow parent change for embedded-image attachment
+ if ($this->isEmbeddedImageAttachment()) {
+ throw new Exception(
+ "Cannot change parent item of embedded-image attachment",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ }
+
+ //
+ // Note or attachment note
+ //
+ if (!empty($this->changed['note'])) {
+ if (!$this->isNote() && !$this->isAttachment()) {
+ throw new Exception("Only notes and attachments can have notes");
+ }
+ if ($this->isEmbeddedImageAttachment()) {
+ throw new Exception("Embedded image attachments cannot have notes");
+ }
+
+ // If we don't have a sanitized note, generate one
+ if (is_null($this->noteTextSanitized)) {
+ $noteTextSanitized = Zotero_Notes::sanitize($this->noteText);
+ // But if note is sanitized already, store empty string
+ if ($this->noteText == $noteTextSanitized) {
+ $this->noteTextSanitized = '';
+ }
+ else {
+ $this->noteTextSanitized = $noteTextSanitized;
+ }
+ }
+
+ $this->noteTitle = Zotero_Notes::noteToTitle(
+ $this->noteTextSanitized === '' ? $this->noteText : $this->noteTextSanitized
+ );
+
+ // Only record sourceItemID in itemNotes for notes
+ if ($this->isNote()) {
+ $sourceItemID = $this->getSource();
+ }
+ $sourceItemID = !empty($sourceItemID) ? $sourceItemID : null;
+ $hash = $this->noteText ? md5($this->noteText) : '';
+ $sql = "INSERT INTO itemNotes "
+ . "(itemID, sourceItemID, note, noteSanitized, title, hash) "
+ . "VALUES (?,?,?,?,?,?) "
+ . "ON DUPLICATE KEY UPDATE "
+ . "sourceItemID=VALUES(sourceItemID), "
+ . "note=VALUES(note), "
+ . "noteSanitized=VALUES(noteSanitized), "
+ . "title=VALUES(title), "
+ . "hash=VALUES(hash)";
+ $bindParams = array(
+ $this->_id,
+ $sourceItemID, $this->noteText, $this->noteTextSanitized, $this->noteTitle, $hash
+ );
+ Zotero_DB::query($sql, $bindParams, $shardID);
+ Zotero_Notes::updateNoteCache($this->_libraryID, $this->_id, $this->noteText);
+ Zotero_Notes::updateHash($this->_libraryID, $this->_id, $hash);
+
+ // TODO: handle changed source?
+ }
+
+ // Attachment
+ if (!empty($this->changed['attachmentData'])) {
+ $isEmbeddedImage = $this->attachmentLinkMode == 'embedded_image';
+
+ $sql = "INSERT INTO itemAttachments
+ (
+ itemID,
+ sourceItemID,
+ linkMode,
+ mimeType,
+ charsetID,
+ path,
+ storageModTime,
+ storageHash
+ )
+ VALUES (?,?,?,?,?,?,?,?)
+ ON DUPLICATE KEY UPDATE
+ sourceItemID=VALUES(sourceItemID),
+ linkMode=VALUES(linkMode),
+ mimeType=VALUES(mimeType),
+ charsetID=VALUES(charsetID),
+ path=VALUES(path),
+ storageModTime=VALUES(storageModTime),
+ storageHash=VALUES(storageHash)";
+ $parent = $this->getSource();
+ if ($parent) {
+ $parentItem = Zotero_Items::get($this->_libraryID, $parent);
+ if (!$parentItem) {
+ throw new Exception("Parent item $parent not found");
+ }
+ if ($parentItem->getSource()) {
+ // Only embedded-image attachments can have child items as parents
+ if (!$isEmbeddedImage) {
+ $parentKey = $parentItem->key;
+ throw new Exception("=Parent item $parentKey cannot be a child attachment", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ // Parent item must be a regular item, or, if this is an embedded image, a
+ // note
+ if (!($parentItem->isRegularItem()
+ || ($isEmbeddedImage && $parentItem->isNote()))) {
+ throw new Exception(
+ // Keep in sync with Errors.inc.php
+ "Parent item $this->_libraryID/$parentItem->key cannot be a note or attachment",
+ Z_ERROR_INVALID_ITEM_PARENT
+ );
+ }
+ }
+
+ $linkMode = Zotero_Attachments::linkModeNameToNumber($this->attachmentLinkMode);
+ $charsetID = Zotero_CharacterSets::getID($this->attachmentCharset);
+ $path = $this->attachmentPath;
+ $storageModTime = $this->attachmentStorageModTime;
+ $storageHash = $this->attachmentStorageHash;
+
+ $bindParams = array(
+ $this->_id,
+ $parent ? $parent : null,
+ $linkMode + 1,
+ $this->attachmentMIMEType,
+ $charsetID ? $charsetID : null,
+ $path ? $path : '',
+ $storageModTime ? $storageModTime : null,
+ $storageHash ? $storageHash : null
+ );
+ Zotero_DB::query($sql, $bindParams, $shardID);
+
+ // If the storage hash changed, clear the file association. We can't just
+ // associate with an existing file if one exists because the file might be
+ // stored in WebDAV, and we don't want to affect the user's quota.
+ if (!empty($this->changed['attachmentData']['storageHash'])) {
+ Zotero_Storage::deleteFileItemInfo($this);
+ }
+ }
+
+ // Annotation
+ if (!empty($this->changed['annotationData'])) {
+ if (!empty($this->annotationText) && $this->annotationType != 'highlight') {
+ throw new Exception(
+ "'annotationText' can only be set for highlight annotations",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+
+ $color = $this->annotationColor;
+ if ($color) {
+ // Strip '#' from hex color
+ if (!preg_match('/^#[0-9a-f]{6}$/', $color)) {
+ throw new Exception("Invalid annotationColor");
+ }
+ $color = substr($color, 1);
+ }
+
+ $sql = "INSERT INTO itemAnnotations "
+ . "(itemID, parentItemID, `type`, authorName, text, comment, color, pageLabel, sortIndex, position) "
+ . "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
+ . "ON DUPLICATE KEY UPDATE "
+ . "authorName=VALUES(authorName), "
+ . "text=VALUES(text), "
+ . "comment=VALUES(comment), "
+ . "color=VALUES(color), "
+ . "pageLabel=VALUES(pageLabel), "
+ . "sortIndex=VALUES(sortIndex), "
+ . "position=VALUES(position)";
+ $params = [
+ $this->_id,
+ $this->getSource(),
+ $this->annotationType,
+ $this->annotationAuthorName,
+ $this->annotationText,
+ $this->annotationComment,
+ $color,
+ $this->annotationPageLabel,
+ $this->annotationSortIndex,
+ $this->annotationPosition,
+ ];
+ Zotero_DB::query($sql, $params, $shardID);
+ }
+
+ // Sort fields
+ if (!empty($this->changed['primaryData']['itemTypeID'])
+ || !empty($this->changed['itemData'])
+ || !empty($this->changed['creators'])) {
+ $sql = "UPDATE itemSortFields SET sortTitle=?";
+ $params = array();
+
+ $sortTitle = Zotero_Items::getSortTitle($this->getDisplayTitle(true));
+ $title = $this->getField('title', false, true);
+ if (mb_substr($sortTitle ?? '', 0, 5) == mb_substr($title ?? '', 0, 5)) {
+ $sortTitle = null;
+ }
+ $params[] = $sortTitle;
+
+ if (!empty($this->changed['creators'])) {
+ $creatorSummary = mb_strcut($this->getCreatorSummary(true), 0, Zotero_Creators::$creatorSummarySortLength);
+ $sql .= ", creatorSummary=?";
+ $params[] = $creatorSummary;
+ }
+
+ $sql .= " WHERE itemID=?";
+ $params[] = $this->_id;
+
+ Zotero_DB::query($sql, $params, $shardID);
+ }
+
+ //
+ // Source item id
+ //
+ if (!empty($this->changed['source'])) {
+ $type = Zotero_ItemTypes::getName($this->_itemTypeID);
+ $Type = ucwords($type);
+
+ $parent = $this->getSource();
+
+ // Update DB, if not a note, attachment, or annotation we already changed above
+ if ((empty($this->changed['note']) || !$this->isNote())
+ && empty($this->changed['attachmentData'])
+ && empty($this->changed['annotationData'])) {
+ $column = $this->isAnnotation() ? "parentItemID" : "sourceItemID";
+ $sql = "UPDATE item" . $Type . "s SET $column=? WHERE itemID=?";
+ $bindParams = array(
+ $parent ? $parent : null,
+ $this->_id
+ );
+ Zotero_DB::query($sql, $bindParams, $shardID);
+ }
+
+ $descendantItemIDs = $this->getDescendants();
+ // If there's a parent item, find the top-level item and set it for this and any
+ // descendant items
+ if ($parent) {
+ $descendantItemIDs[] = $this->_id;
+ $topLevelItemID = $parent;
+ $topLevelItem = Zotero_Items::get($this->_libraryID, $topLevelItemID);
+ while ($nextID = $topLevelItem->getSource()) {
+ $topLevelItemID = $nextID;
+ $topLevelItem = Zotero_Items::get($this->_libraryID, $topLevelItemID);
+ }
+
+ Zotero_Items::setTopLevelItem($descendantItemIDs, $topLevelItemID, $shardID);
+ }
+ // If no parent, clear this item's top-level item and set this item as the
+ // top-level item for any descendant items
+ else {
+ Zotero_Items::clearTopLevelItem($this->_id, $shardID);
+ Zotero_Items::setTopLevelItem($descendantItemIDs, $this->_id, $shardID);
+ }
+ }
+
+
+ if (false && !empty($this->changed['source'])) {
+ trigger_error("Unimplemented", E_USER_ERROR);
+
+ $newItem = Zotero_Items::get($this->_libraryID, $sourceItemID);
+ // FK check
+ if ($newItem) {
+ if ($sourceItemID) {
+ }
+ else {
+ trigger_error("Cannot set $type source to invalid item $sourceItemID", E_USER_ERROR);
+ }
+ }
+
+ $oldSourceItemID = $this->getSource();
+
+ if ($oldSourceItemID == $sourceItemID) {
+ Z_Core::debug("$Type source hasn't changed", 4);
+ }
+ else {
+ $oldItem = Zotero_Items::get($this->_libraryID, $oldSourceItemID);
+ if ($oldSourceItemID && $oldItem) {
+ }
+ else {
+ //$oldItemNotifierData = null;
+ Z_Core::debug("Old source item $oldSourceItemID didn't exist in setSource()", 2);
+ }
+
+ // If this was an independent item, remove from any collections where it
+ // existed previously and add source instead if there is one
+ if (!$oldSourceItemID) {
+ $sql = "SELECT collectionID FROM collectionItems WHERE itemID=?";
+ $changedCollections = Zotero_DB::query($sql, $itemID, $shardID);
+ if ($changedCollections) {
+ trigger_error("Unimplemented", E_USER_ERROR);
+ if ($sourceItemID) {
+ $sql = "UPDATE OR REPLACE collectionItems "
+ . "SET itemID=? WHERE itemID=?";
+ Zotero_DB::query($sql, array($sourceItemID, $this->_id), $shardID);
+ }
+ else {
+ $sql = "DELETE FROM collectionItems WHERE itemID=?";
+ Zotero_DB::query($sql, $this->_id, $shardID);
+ }
+ }
+ }
+
+ $sql = "UPDATE item{$Type}s SET sourceItemID=?
+ WHERE itemID=?";
+ $bindParams = array(
+ $sourceItemID ? $sourceItemID : null,
+ $itemID
+ );
+ Zotero_DB::query($sql, $bindParams, $shardID);
+
+ //Zotero.Notifier.trigger('modify', 'item', $this->_id, notifierData);
+
+ // Update the counts of the previous and new sources
+ if ($oldItem) {
+ /*
+ switch ($type) {
+ case 'note':
+ $oldItem->decrementNoteCount();
+ break;
+ case 'attachment':
+ $oldItem->decrementAttachmentCount();
+ break;
+ }
+ */
+ //Zotero.Notifier.trigger('modify', 'item', oldSourceItemID, oldItemNotifierData);
+ }
+
+ if ($newItem) {
+ /*
+ switch ($type) {
+ case 'note':
+ $newItem->incrementNoteCount();
+ break;
+ case 'attachment':
+ $newItem->incrementAttachmentCount();
+ break;
+ }
+ */
+ //Zotero.Notifier.trigger('modify', 'item', sourceItemID, newItemNotifierData);
+ }
+ }
+ }
+
+ // Collections
+ if (!empty($this->changed['collections'])) {
+ $oldCollections = $this->previousData['collections'];
+ $newCollections = $this->collections;
+
+ $toAdd = array_diff($newCollections, $oldCollections);
+ $toRemove = array_diff($oldCollections, $newCollections);
+
+ foreach ($toAdd as $collectionKey) {
+ $collection = Zotero_Collections::getByLibraryAndKey($this->_libraryID, $collectionKey);
+ if (!$collection) {
+ throw new Exception(
+ "Collection $this->_libraryID/$collectionKey doesn't exist",
+ Z_ERROR_COLLECTION_NOT_FOUND
+ );
+ }
+ $collection->addItem($this->_id);
+ }
+
+ foreach ($toRemove as $collectionKey) {
+ $collection = Zotero_Collections::getByLibraryAndKey($this->_libraryID, $collectionKey);
+ $collection->removeItem($this->_id);
+ }
+ }
+
+ if (!empty($this->changed['tags'])) {
+ $oldTags = $this->previousData['tags'];
+ $newTags = $this->tags;
+
+ $cmp = function ($a, $b) {
+ return strcmp($a->name . $a->type, $b->name . $b->type);
+ };
+ $toAdd = array_udiff($newTags, $oldTags, $cmp);
+ $toRemove = array_udiff($oldTags, $newTags, $cmp);
+
+ foreach ($toAdd as $tag) {
+ $name = $tag->name;
+ $type = $tag->type;
+
+ $tagID = Zotero_Tags::getID($this->_libraryID, $name, $type);
+ if (!$tagID) {
+ $tag = new Zotero_Tag;
+ $tag->libraryID = $this->_libraryID;
+ $tag->name = $name;
+ $tag->type = $type;
+ $tagID = $tag->save();
+ }
+
+ $tag = Zotero_Tags::get($this->_libraryID, $tagID);
+ $tag->addItem($this->_key);
+ $tag->save();
+ }
+
+ foreach ($toRemove as $tag) {
+ $tag->removeItem($this->_key);
+ $tag->save();
+ }
+ }
+
+ // Related items
+ if (!empty($this->changed['relations'])) {
+ $removed = [];
+ $new = [];
+ $current = $this->relations;
+
+ // TEMP
+ // Convert old-style related items into relations
+ $sql = "SELECT `key` FROM itemRelated IR "
+ . "JOIN items I ON (IR.linkedItemID=I.itemID) "
+ . "WHERE IR.itemID=?";
+ $toMigrate = Zotero_DB::columnQuery($sql, $this->_id, $shardID);
+ if ($toMigrate) {
+ $prefix = Zotero_URI::getLibraryURI($this->_libraryID) . "/items/";
+ $new = array_map(function ($key) use ($prefix) {
+ return [
+ Zotero_Relations::$relatedItemPredicate,
+ $prefix . $key
+ ];
+ }, $toMigrate);
+ $sql = "DELETE FROM itemRelated WHERE itemID=?";
+ Zotero_DB::query($sql, $this->_id, $shardID);
+ }
+
+ foreach ($this->previousData['relations'] as $rel) {
+ if (array_search($rel, $current) === false) {
+ $removed[] = $rel;
+ }
+ }
+
+ foreach ($current as $rel) {
+ if (array_search($rel, $this->previousData['relations']) !== false) {
+ continue;
+ }
+ $new[] = $rel;
+ }
+
+ $uri = Zotero_URI::getItemURI($this);
+
+ if ($removed) {
+ $sql = "DELETE FROM relations WHERE libraryID=? AND `key`=?";
+ $deleteStatement = Zotero_DB::getStatement($sql, false, $shardID);
+
+ foreach ($removed as $rel) {
+ $params = [
+ $this->_libraryID,
+ Zotero_Relations::makeKey($uri, $rel[0], $rel[1])
+ ];
+ $deleteStatement->execute($params);
+
+ // TEMP
+ // For owl:sameAs, delete reverse as well, since the client
+ // can save that way
+ if ($rel[0] == Zotero_Relations::$linkedObjectPredicate) {
+ $params = [
+ $this->_libraryID,
+ Zotero_Relations::makeKey($rel[1], $rel[0], $uri)
+ ];
+ $deleteStatement->execute($params);
+ }
+ }
+ }
+
+ if ($new) {
+ $sql = "INSERT IGNORE INTO relations "
+ . "(relationID, libraryID, `key`, subject, predicate, object) "
+ . "VALUES (?, ?, ?, ?, ?, ?)";
+ $insertStatement = Zotero_DB::getStatement($sql, false, $shardID);
+
+ foreach ($new as $rel) {
+ $insertStatement->execute(
+ array(
+ Zotero_ID::get('relations'),
+ $this->_libraryID,
+ Zotero_Relations::makeKey($uri, $rel[0], $rel[1]),
+ $uri,
+ $rel[0],
+ $rel[1]
+ )
+ );
+
+ // If adding a related item, the version on that item has to be
+ // updated as well (if it exists). Otherwise, requests for that
+ // item will return cached data without the new relation.
+ if ($rel[0] == Zotero_Relations::$relatedItemPredicate) {
+ $relatedItem = Zotero_URI::getURIItem($rel[1]);
+ if (!$relatedItem) {
+ Z_Core::debug("Related item " . $rel[1] . " does not exist "
+ . "for item " . $this->libraryKey);
+ continue;
+ }
+ // If item has already changed, assume something else is taking
+ // care of saving it and don't do so now, to avoid endless loops
+ // with circular relations
+ if ($relatedItem->hasChanged()) {
+ continue;
+ }
+ $relatedItem->updateVersion($userID);
+ }
+ }
+ }
+ }
+ }
+
+ Zotero_DB::commit();
+ }
+
+ catch (Exception $e) {
+ Zotero_DB::rollback();
+ throw ($e);
+ }
+
+ $this->cacheEnabled = false;
+
+ $this->finalizeSave($env);
+
+ if ($isNew) {
+ Zotero_Notifier::trigger('add', 'item', $this->_libraryID . "/" . $this->_key);
+ return $this->_id;
+ }
+
+ Zotero_Notifier::trigger('modify', 'item', $this->_libraryID . "/" . $this->_key);
+ return true;
+ }
+
+
+ /**
+ * Update the item's version without changing any data
+ */
+ public function updateVersion($userID) {
+ $this->changed['version'] = true;
+ $this->save($userID);
+ }
+
+
+ /*
+ * Returns the number of creators for this item
+ */
+ public function numCreators() {
+ if ($this->id && !$this->loaded['creators']) {
+ $this->loadCreators();
+ }
+ return sizeOf($this->creators);
+ }
+
+
+ /**
+ * @param int
+ * @return Zotero_Creator
+ */
+ public function getCreator($orderIndex) {
+ if ($this->id && !$this->loaded['creators']) {
+ $this->loadCreators();
+ }
+
+ return isset($this->creators[$orderIndex])
+ ? $this->creators[$orderIndex] : false;
+ }
+
+
+ /**
+ * Gets the creators in this object
+ *
+ * @return array Array of Zotero_Creator objects
+ */
+ public function getCreators() {
+ if ($this->id && !$this->loaded['creators']) {
+ $this->loadCreators();
+ }
+ return $this->creators;
+ }
+
+
+ public function setCreator($orderIndex, Zotero_Creator $creator, $creatorTypeID) {
+ if ($this->id && !$this->loaded['creators']) {
+ $this->loadCreators();
+ }
+ else {
+ $this->loaded['creators'] = true;
+ }
+
+ if (!is_integer($orderIndex)) {
+ throw new Exception("orderIndex must be an integer");
+ }
+ if (!($creator instanceof Zotero_Creator)) {
+ throw new Exception("creator must be a Zotero_Creator object");
+ }
+ if (!is_integer($creatorTypeID)) {
+ throw new Exception("creatorTypeID must be an integer");
+ }
+ if (!Zotero_CreatorTypes::getID($creatorTypeID)) {
+ throw new Exception("Invalid creatorTypeID '$creatorTypeID'");
+ }
+ if ($this->libraryID != $creator->libraryID) {
+ throw new Exception("Creator library IDs don't match");
+ }
+
+ // If creatorTypeID isn't valid for this type, use the primary type
+ if (!Zotero_CreatorTypes::isValidForItemType($creatorTypeID, $this->itemTypeID)) {
+ $msg = "Invalid creator type $creatorTypeID for item type " . $this->itemTypeID
+ . " -- changing to primary creator";
+ Z_Core::debug($msg);
+ $creatorTypeID = Zotero_CreatorTypes::getPrimaryIDForType($this->itemTypeID);
+ }
+
+ // If creator already exists at this position, cancel
+ if (isset($this->creators[$orderIndex])
+ && $this->creators[$orderIndex]['ref']->id == $creator->id
+ && $this->creators[$orderIndex]['creatorTypeID'] == $creatorTypeID
+ && !$creator->hasChanged()) {
+ Z_Core::debug("Creator in position $orderIndex hasn't changed", 4);
+ return false;
+ }
+
+ $this->creators[$orderIndex]['ref'] = $creator;
+ $this->creators[$orderIndex]['creatorTypeID'] = $creatorTypeID;
+ $this->changed['creators'][$orderIndex] = true;
+ return true;
+ }
+
+
+ /*
+ * Remove a creator and shift others down
+ */
+ public function removeCreator($orderIndex) {
+ if ($this->id && !$this->loaded['creators']) {
+ $this->loadCreators();
+ }
+
+ if (!isset($this->creators[$orderIndex])) {
+ trigger_error("No creator exists at position $orderIndex", E_USER_ERROR);
+ }
+
+ $this->creators[$orderIndex] = false;
+ array_splice($this->creators, $orderIndex, 1);
+ for ($i=$orderIndex, $max=sizeOf($this->creators)+1; $i<$max; $i++) {
+ $this->changed['creators'][$i] = true;
+ }
+ return true;
+ }
+
+
+ public function isRegularItem() {
+ return !($this->isNote() || $this->isAttachment() || $this->isAnnotation());
+ }
+
+
+ public function isTopLevelItem() {
+ return $this->isRegularItem() || !$this->getSourceKey();
+ }
+
+
+ public function numChildren($includeTrashed=false) {
+ if ($this->isRegularItem()) {
+ return $this->numNotes($includeTrashed) + $this->numAttachments($includeTrashed);
+ }
+ if ($this->isNote()) {
+ return $this->numAttachments($includeTrashed);
+ }
+ if ($this->isPDFAttachment()) {
+ return $this->numAnnotations($includeTrashed);
+ }
+ throw new Exception("Invalid item type");
+ }
+
+ // TODO: Cache
+ public function numPublicationsChildren() {
+ if (!$this->isRegularItem()) {
+ throw new Exception("numPublicationsNotes() cannot be called on note or attachment items");
+ }
+
+ if (!$this->id) {
+ return 0;
+ }
+
+ $shardID = Zotero_Shards::getByLibraryID($this->libraryID);
+
+ $sql = "SELECT COUNT(*) FROM itemNotes INo "
+ . "JOIN publicationsItems PI USING (itemID) "
+ . "LEFT JOIN deletedItems DI USING (itemID) "
+ . "WHERE INo.sourceItemID=? AND DI.itemID IS NULL";
+ $numNotes = Zotero_DB::valueQuery($sql, $this->id, $shardID);
+
+ $sql = "SELECT COUNT(*) FROM itemAttachments IA "
+ . "JOIN publicationsItems PI USING (itemID) "
+ . "LEFT JOIN deletedItems DI USING (itemID) "
+ . "WHERE IA.sourceItemID=? AND DI.itemID IS NULL";
+ $numAttachments = Zotero_DB::valueQuery($sql, $this->id, $shardID);
+
+ return $numNotes + $numAttachments;
+ }
+
+
+ //
+ //
+ // Child item methods
+ //
+ //
+ public function getDescendants() {
+ $isRegularItem = $this->isRegularItem();
+ $isNote = $this->isNote();
+ $isFileAttachment = $this->isFileAttachment() && !$this->isEmbeddedImageAttachment();
+
+ if (!($isRegularItem || $isNote || $isFileAttachment)) {
+ return [];
+ }
+
+ $id = $this->id;
+ $shardID = Zotero_Shards::getByLibraryID($this->_libraryID);
+
+ // Get child items
+ $sqlParts = [];
+ $sqlParams = [];
+ if ($isRegularItem || $isNote) {
+ $sqlParts[] = "SELECT itemID FROM itemAttachments WHERE sourceItemID=?";
+ $sqlParams[] = $id;
+ }
+ if ($isRegularItem) {
+ $sqlParts[] = "SELECT itemID FROM itemNotes WHERE sourceItemID=?";
+ $sqlParams[] = $id;
+ }
+ if ($isFileAttachment) {
+ $sqlParts[] = "SELECT itemID FROM itemAnnotations WHERE parentItemID=?";
+ $sqlParams[] = $id;
+ }
+ $itemIDs = Zotero_DB::columnQuery(implode(" UNION ", $sqlParts), $sqlParams, $shardID);
+ if (!$itemIDs) {
+ return [];
+ }
+
+ // Get descendant items of child items, recursively
+ foreach ($itemIDs as $itemID) {
+ $item = Zotero_Items::get($this->_libraryID, $itemID);
+ $descendentItemIDs = $item->getDescendants();
+ // TODO: Remove conditional after upgrade to PHP 7.3 -- 7.2 logs warning on empty array
+ if ($descendentItemIDs) {
+ array_push($itemIDs, ...$descendentItemIDs);
+ }
+ }
+
+ return $itemIDs;
+ }
+
+
+ /**
+ * Get the itemID of the source item for a note or file
+ **/
+ public function getSource() {
+ if (isset($this->sourceItem)) {
+ if (!$this->sourceItem) {
+ return false;
+ }
+ if (is_int($this->sourceItem)) {
+ return $this->sourceItem;
+ }
+ $sourceItem = Zotero_Items::getByLibraryAndKey($this->libraryID, $this->sourceItem);
+ if (!$sourceItem) {
+ // Keep in sync with Zotero_Errors::parseException
+ throw new Exception("Parent item $this->libraryID/$this->sourceItem doesn't exist", Z_ERROR_ITEM_NOT_FOUND);
+ }
+ // Replace stored key with id
+ $this->sourceItem = $sourceItem->id;
+ return $sourceItem->id;
+ }
+
+ if (!$this->id) {
+ return false;
+ }
+
+ if ($this->isNote()) {
+ $Type = 'Note';
+ }
+ else if ($this->isAttachment()) {
+ $Type = 'Attachment';
+ }
+ else if ($this->isAnnotation()) {
+ $Type = 'Annotation';
+ }
+ else {
+ return false;
+ }
+
+ if ($this->cacheEnabled) {
+ $cacheVersion = 1;
+ $cacheKey = $this->getCacheKey("itemSource",
+ $cacheVersion
+ . isset(Z_CONFIG::$CACHE_VERSION_ITEM_DATA)
+ ? "_" . Z_CONFIG::$CACHE_VERSION_ITEM_DATA
+ : ""
+ );
+ $sourceItemID = Z_Core::$MC->get($cacheKey);
+ }
+ else {
+ $sourceItemID = false;
+ }
+ if ($sourceItemID === false) {
+ $col = $Type == 'Annotation' ? 'parentItemID' : 'sourceItemID';
+ $sql = "SELECT $col FROM item{$Type}s WHERE itemID=?";
+ $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
+ $sourceItemID = Zotero_DB::valueQueryFromStatement($stmt, $this->id);
+
+ if ($this->cacheEnabled) {
+ Z_Core::$MC->set($cacheKey, $sourceItemID ? $sourceItemID : 0);
+ }
+ }
+
+ if (!$sourceItemID) {
+ $sourceItemID = false;
+ }
+ $this->sourceItem = $sourceItemID;
+ return $sourceItemID;
+ }
+
+
+ /**
+ * Get the key of the source item for a note or file
+ * @return {String}
+ */
+ public function getSourceKey() {
+ if (isset($this->sourceItem)) {
+ if (is_int($this->sourceItem)) {
+ $sourceItem = Zotero_Items::get($this->libraryID, $this->sourceItem);
+ return $sourceItem->key;
+ }
+ return $this->sourceItem;
+ }
+
+ if (!$this->id) {
+ return false;
+ }
+
+ if ($this->isNote()) {
+ $Type = 'Note';
+ }
+ else if ($this->isAttachment()) {
+ $Type = 'Attachment';
+ }
+ else if ($this->isAnnotation()) {
+ $Type = 'Annotation';
+ }
+ else {
+ return false;
+ }
+
+ $col = $Type == 'Annotation' ? 'parentItemID' : 'sourceItemID';
+ $sql = "SELECT `key` FROM item{$Type}s A JOIN items B ON (A.$col=B.itemID) WHERE A.itemID=?";
+ $key = Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+ if (!$key) {
+ $key = false;
+ }
+ $this->sourceItem = $key;
+ return $key;
+ }
+
+
+ public function setSource($sourceItemID) {
+ if ($this->isNote()) {
+ $type = 'note';
+ $Type = 'Note';
+ }
+ else if ($this->isAttachment()) {
+ $type = 'attachment';
+ $Type = 'Attachment';
+ }
+ else if ($this->isAnnotation()) {
+ $type = 'annotation';
+ $Type = 'Annotation';
+ }
+ else {
+ throw new Exception("setSource() can be called only on notes, attachments, and annotations");
+ }
+
+ $this->sourceItem = $sourceItemID;
+ $this->changed['source'] = true;
+ }
+
+
+ public function setSourceKey($sourceItemKey) {
+ if ($this->isNote()) {
+ $type = 'note';
+ $Type = 'Note';
+ }
+ else if ($this->isAttachment()) {
+ $type = 'attachment';
+ $Type = 'Attachment';
+ }
+ else if ($this->isAnnotation()) {
+ $type = 'annotation';
+ $Type = 'Annotation';
+ }
+ else {
+ throw new Exception("setSourceKey() can be called only on notes, attachments, and annotations");
+ }
+
+ $oldSourceItemID = $this->getSource();
+ if ($oldSourceItemID) {
+ $sourceItem = Zotero_Items::get($this->libraryID, $oldSourceItemID);
+ $oldSourceItemKey = $sourceItem->key;
+ }
+ else {
+ $oldSourceItemKey = null;
+ }
+ if ($oldSourceItemKey == $sourceItemKey) {
+ Z_Core::debug("Source item has not changed in Zotero_Item->setSourceKey()");
+ return false;
+ }
+
+ $this->sourceItem = $sourceItemKey ? $sourceItemKey : false;
+ $this->changed['source'] = true;
+
+ return true;
+ }
+
+
+ /**
+ * Returns number of child attachments of item
+ *
+ * @param {Boolean} includeTrashed Include trashed child items in count
+ * @return {Integer}
+ */
+ public function numAttachments($includeTrashed=false) {
+ if (!$this->isRegularItem() && !$this->isNote()) {
+ throw new Exception("numAttachments() can only be called on regular items and notes");
+ }
+
+ if (!$this->id) {
+ return 0;
+ }
+
+ if (!isset($this->numAttachments)) {
+ $sql = "SELECT COUNT(*) FROM itemAttachments WHERE sourceItemID=?";
+ $this->numAttachments = (int) Zotero_DB::valueQuery(
+ $sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)
+ );
+ }
+
+ $deleted = 0;
+ if ($includeTrashed) {
+ $sql = "SELECT COUNT(*) FROM itemAttachments JOIN deletedItems USING (itemID)
+ WHERE sourceItemID=?";
+ $deleted = (int) Zotero_DB::valueQuery(
+ $sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)
+ );
+ }
+
+ return $this->numAttachments + $deleted;
+ }
+
+
+ public function incrementAttachmentCount() {
+ $this->numAttachments++;
+ }
+
+
+ public function decrementAttachmentCount() {
+ $this->numAttachments--;
+ }
+
+
+ //
+ //
+ // Note methods
+ //
+ //
+ /**
+ * Get the first line of the note for display in the items list
+ *
+ * Note: Note titles can also come from Zotero.Items.cacheFields()!
+ *
+ * @return {String}
+ */
+ public function getNoteTitle() {
+ if (!$this->isNote() && !$this->isAttachment()) {
+ throw ("getNoteTitle() can only be called on notes and attachments");
+ }
+
+ if ($this->noteTitle !== null) {
+ return $this->noteTitle;
+ }
+
+ if (!$this->id) {
+ return '';
+ }
+
+ $sql = "SELECT title FROM itemNotes WHERE itemID=?";
+ $title = Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+
+ $this->noteTitle = $title ? $title : '';
+ return $this->noteTitle;
+ }
+
+
+
+ /**
+ * Get the text of an item note
+ **/
+ public function getNote($sanitized=false, $htmlspecialchars=false) {
+ if (!$this->isNote() && !$this->isAttachment()) {
+ throw new Exception("getNote() can only be called on notes and attachments");
+ }
+
+ if (!$this->id) {
+ return '';
+ }
+
+ // Store access time for later garbage collection
+ //$this->noteAccessTime = new Date();
+
+ if ($sanitized) {
+ if ($htmlspecialchars) {
+ throw new Exception('$sanitized and $htmlspecialchars cannot currently be used together');
+ }
+
+ if (is_null($this->noteText)) {
+ $sql = "SELECT note, noteSanitized, serverDateModified FROM itemNotes "
+ . "JOIN items USING (itemID) WHERE itemID=?";
+ $row = Zotero_DB::rowQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+ if (!$row) {
+ $row = ['note' => '', 'noteSanitized' => '', 'serverDateModified' => null];
+ }
+ $this->noteText = $row['note'];
+ if (!$row['serverDateModified'] || $row['serverDateModified'] >= '2017-04-01') {
+ $this->noteTextSanitized = $row['noteSanitized'];
+ }
+ else {
+ $this->noteTextSanitized = Zotero_Notes::sanitize($row['note']);
+ }
+ }
+ // Empty string means the original note is sanitized
+ return $this->noteTextSanitized === '' ? $this->noteText : $this->noteTextSanitized;
+ }
+
+ if (is_null($this->noteText)) {
+ $note = Zotero_Notes::getCachedNote($this->libraryID, $this->id);
+ if ($note === false) {
+ $sql = "SELECT note FROM itemNotes WHERE itemID=?";
+ $note = Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+ }
+ $this->noteText = $note !== false ? $note : '';
+ }
+
+ if ($this->noteText !== '' && $htmlspecialchars) {
+ $noteHash = $this->getNoteHash();
+ if ($noteHash) {
+ $cacheKey = "htmlspecialcharsNote_$noteHash";
+ $note = Z_Core::$MC->get($cacheKey);
+ if ($note === false) {
+ $note = htmlspecialchars($this->noteText);
+ Z_Core::$MC->set($cacheKey, $note);
+ }
+ }
+ else {
+ error_log("WARNING: Note hash is empty");
+ $note = htmlspecialchars($this->noteText);
+ }
+ return $note;
+ }
+
+ return $this->noteText;
+ }
+
+
+ /**
+ * Set an item note
+ *
+ * Note: This can only be called on notes and attachments
+ **/
+ public function setNote($text) {
+ if (!is_string($text)) {
+ $text = '';
+ }
+
+ if (mb_strlen($text) > Zotero_Notes::$MAX_NOTE_LENGTH) {
+ // UTF-8 (0xC2 0xA0) isn't trimmed by default
+ $whitespace = chr(0x20) . chr(0x09) . chr(0x0A) . chr(0x0D)
+ . chr(0x00) . chr(0x0B) . chr(0xC2) . chr(0xA0);
+ $excerpt = iconv(
+ "UTF-8",
+ "UTF-8//IGNORE",
+ Zotero_Notes::noteToTitle(trim($text), true)
+ );
+ $excerpt = trim($excerpt, $whitespace);
+ // If tag-stripped version is empty, just return raw HTML
+ if ($excerpt == '') {
+ $excerpt = iconv(
+ "UTF-8",
+ "UTF-8//IGNORE",
+ preg_replace(
+ '/\s+/',
+ ' ',
+ mb_substr(trim($text), 0, Zotero_Notes::$MAX_TITLE_LENGTH)
+ )
+ );
+ $excerpt = html_entity_decode($excerpt);
+ $excerpt = trim($excerpt, $whitespace);
+ }
+
+ $msg = "=Note '" . $excerpt . "...' too long";
+ if ($this->key) {
+ $msg .= " for item '" . $this->libraryID . "/" . $this->key . "'";
+ }
+ throw new Exception($msg, Z_ERROR_NOTE_TOO_LONG);
+ }
+
+ $sanitizedText = Zotero_Notes::sanitize($text);
+
+ if ($sanitizedText === $this->getNote(true)) {
+ Z_Core::debug("Note text hasn't changed in setNote()");
+ return;
+ }
+
+ $this->noteText = $text;
+ // If sanitized version is the same as original, store empty string
+ if ($text === $sanitizedText) {
+ $this->noteTextSanitized = '';
+ }
+ else {
+ $this->noteTextSanitized = $sanitizedText;
+ }
+ $this->changed['note'] = true;
+ }
+
+
+ /**
+ * Returns number of child notes of item
+ *
+ * @param {Boolean} includeTrashed Include trashed child items in count
+ * @return {Integer}
+ */
+ public function numNotes($includeTrashed=false) {
+ if (!$this->isRegularItem()) {
+ throw new Exception("numNotes() cannot be called on note or attachment items");
+ }
+
+ if (!$this->id) {
+ return 0;
+ }
+
+ if (!isset($this->numNotes)) {
+ $sql = "SELECT COUNT(*) FROM itemNotes WHERE sourceItemID=?";
+ $this->numNotes = (int) Zotero_DB::valueQuery(
+ $sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)
+ );
+ }
+
+ $deleted = 0;
+ if ($includeTrashed) {
+ $sql = "SELECT COUNT(*) FROM itemNotes WHERE sourceItemID=? AND
+ itemID IN (SELECT itemID FROM deletedItems)";
+ $deleted = (int) Zotero_DB::valueQuery(
+ $sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)
+ );
+ }
+
+ return $this->numNotes + $deleted;
+ }
+
+
+ public function incrementNoteCount() {
+ $this->numNotes++;
+ }
+
+
+ public function decrementNoteCount() {
+ $this->numNotes--;
+ }
+
+
+ //
+ //
+ // Methods dealing with item notes
+ //
+ //
+ /**
+ * Returns an array of note itemIDs for this item
+ **/
+ public function getNotes() {
+ if ($this->isNote()) {
+ throw new Exception("getNotes() cannot be called on items of type 'note'");
+ }
+
+ if (!$this->id) {
+ return array();
+ }
+
+ $sql = "SELECT N.itemID FROM itemNotes N NATURAL JOIN items
+ WHERE sourceItemID=? ORDER BY title";
+
+ /*
+ if (Zotero.Prefs.get('sortNotesChronologically')) {
+ sql += " ORDER BY dateAdded";
+ return Zotero.DB.columnQuery(sql, $this->id);
+ }
+ */
+
+ $itemIDs = Zotero_DB::columnQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+ if (!$itemIDs) {
+ return array();
+ }
+ return $itemIDs;
+ }
+
+
+ //
+ //
+ // Attachment methods
+ //
+ //
+ /**
+ * Get the link mode of an attachment
+ *
+ * @return {String} - Possible return values specified Zotero.Attachments (e.g. 'imported_url')
+ */
+ private function getAttachmentLinkMode() {
+ if (!$this->isAttachment()) {
+ throw new Exception("attachmentLinkMode can only be retrieved for attachment items");
+ }
+
+ if ($this->attachmentData['linkMode'] !== null) {
+ return $this->attachmentData['linkMode'];
+ }
+
+ if (!$this->id) {
+ return null;
+ }
+
+ // Return ENUM as 0-index integer
+ $sql = "SELECT linkMode - 1 FROM itemAttachments WHERE itemID=?";
+ $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
+ // DEBUG: why is this returned as a float without the cast?
+ $linkMode = (int) Zotero_DB::valueQueryFromStatement($stmt, $this->id);
+ return $this->attachmentData['linkMode'] = Zotero_Attachments::linkModeNumberToName($linkMode);
+ }
+
+
+ /**
+ * Get the MIME type of an attachment (e.g. 'text/plain')
+ */
+ private function getAttachmentMIMEType() {
+ if (!$this->isAttachment()) {
+ trigger_error("attachmentMIMEType can only be retrieved for attachment items", E_USER_ERROR);
+ }
+
+ if ($this->attachmentData['mimeType'] !== null) {
+ return $this->attachmentData['mimeType'];
+ }
+
+ if (!$this->id) {
+ return '';
+ }
+
+ $sql = "SELECT mimeType FROM itemAttachments WHERE itemID=?";
+ $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
+ $mimeType = Zotero_DB::valueQueryFromStatement($stmt, $this->id);
+ if (!$mimeType) {
+ $mimeType = '';
+ }
+
+ // TEMP: Strip some invalid characters
+ $mimeType = iconv("UTF-8", "ASCII//IGNORE", $mimeType);
+ $mimeType = preg_replace('/[^\x{0009}\x{000a}\x{000d}\x{0020}-\x{D7FF}\x{E000}-\x{FFFD}]+/u', '', $mimeType);
+
+ $this->attachmentData['mimeType'] = $mimeType;
+ return $mimeType;
+ }
+
+
+ /**
+ * Get the character set of an attachment
+ *
+ * @return string Character set name
+ */
+ private function getAttachmentCharset() {
+ if (!$this->isAttachment()) {
+ trigger_error("attachmentCharset can only be retrieved for attachment items", E_USER_ERROR);
+ }
+
+ if ($this->attachmentData['charset'] !== null) {
+ return $this->attachmentData['charset'];
+ }
+
+ if (!$this->id) {
+ return '';
+ }
+
+ $sql = "SELECT charsetID FROM itemAttachments WHERE itemID=?";
+ $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
+ $charset = Zotero_DB::valueQueryFromStatement($stmt, $this->id);
+ if ($charset) {
+ $charset = Zotero_CharacterSets::getName($charset);
+ }
+ else {
+ $charset = '';
+ }
+
+ $this->attachmentData['charset'] = $charset;
+ return $charset;
+ }
+
+
+ private function getAttachmentFilename() {
+ if (!$this->isAttachment()) {
+ throw new Exception("attachmentFilename can only be retrieved for attachment items");
+ }
+
+ if (!$this->isStoredFileAttachment()) {
+ throw new Exception("attachmentFilename cannot be retrieved for linked attachments");
+ }
+
+ if ($this->attachmentData['filename'] !== null) {
+ return $this->attachmentData['filename'];
+ }
+
+ if (!$this->id) {
+ return '';
+ }
+
+ $path = $this->attachmentPath;
+ if (!$path) {
+ return '';
+ }
+
+ // Strip "storage:"
+ $filename = substr($path, 8);
+ // TODO: Remove after classic sync is remove and existing values are batch-converted
+ $filename = Zotero_Attachments::decodeRelativeDescriptorString($filename);
+
+ $this->attachmentData['filename'] = $filename;
+ return $filename;
+ }
+
+
+ private function getAttachmentField($field) {
+ $fullField = "attachment" . ucfirst($field);
+ if (!$this->isAttachment()) {
+ throw new Exception("$fullField can only be retrieved for attachment items");
+ }
+
+ switch ($field) {
+ case 'path':
+ $defaultType = 'string';
+ break;
+
+ case 'storageModTime':
+ case 'storageHash':
+ $defaultType = 'null';
+ break;
+
+ default:
+ throw new Exception("Invalid field '$field'");
+ }
+
+ if ($this->attachmentData[$field] !== null) {
+ return $this->attachmentData[$field];
+ }
+
+ if (!$this->id) {
+ return $defaultType == 'string' ? '' : null;
+ }
+
+ $sql = "SELECT $field FROM itemAttachments WHERE itemID=?";
+ $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
+ $val = Zotero_DB::valueQueryFromStatement($stmt, $this->id);
+
+ if ($defaultType == 'string') {
+ if (!$val) {
+ $val = '';
+ }
+ }
+ else if ($defaultType == 'null') {
+ if ($val === false) {
+ $val = null;
+ }
+ }
+
+ $this->attachmentData[$field] = $val;
+ return $val;
+ }
+
+
+ private function setAttachmentField($field, $val) {
+ Z_Core::debug("Setting attachment field $field to '$val'");
+ switch ($field) {
+ case 'mimeType':
+ $field = 'mimeType';
+ $fieldCap = 'MIMEType';
+ break;
+
+ case 'linkMode':
+ case 'charset':
+ case 'storageModTime':
+ case 'storageHash':
+ case 'path':
+ case 'filename':
+ $fieldCap = ucwords($field);
+ break;
+
+ default:
+ trigger_error("Invalid attachment field $field", E_USER_ERROR);
+ }
+
+ // Clean value
+ switch ($field) {
+ // Default to string
+ case 'mimeType':
+ case 'charset':
+ case 'path':
+ case 'filename':
+ if (!$val) {
+ $val = '';
+ }
+ break;
+
+ case 'linkMode':
+ if (is_numeric($val)) {
+ $val = Zotero_Attachments::linkModeNumberToName($val);
+ }
+ // Validate
+ else {
+ Zotero_Attachments::linkModeNameToNumber($val);
+ }
+ break;
+
+ // Default to null
+ case 'storageModTime':
+ case 'storageHash':
+ if (!$val) {
+ $val = null;
+ }
+ break;
+ }
+
+ if (!$this->isAttachment()) {
+ trigger_error("attachment$fieldCap can only be set for attachment items", E_USER_ERROR);
+ }
+
+ $linkMode = $this->getAttachmentLinkMode();
+
+ if ($linkMode == "linked_file" && Zotero_Libraries::getType($this->libraryID) != 'user') {
+ throw new Exception(
+ "Linked files can only be added to user libraries", Z_ERROR_INVALID_INPUT
+ );
+ }
+
+ if ($field == 'filename') {
+ if ($linkMode == "linked_url") {
+ throw new Exception("Linked URLs cannot have filenames");
+ }
+ else if ($linkMode == "linked_file") {
+ throw new Exception("Cannot change filename for linked file");
+ }
+
+ $field = 'path';
+ $fieldCap = 'Path';
+ $val = 'storage:' . Zotero_Attachments::encodeRelativeDescriptorString($val);
+ }
+
+ /*if (!is_int($val) && !$val) {
+ $val = '';
+ }*/
+
+ $fieldName = 'attachment' . $fieldCap;
+
+ if ($val === $this->$fieldName) {
+ return;
+ }
+
+ // Don't allow changing of existing linkMode
+ if ($field == 'linkMode' && $this->$fieldName !== null) {
+ throw new Exception("Cannot change existing linkMode for item "
+ . $this->libraryID . "/" . $this->key);
+ }
+
+ $this->changed['attachmentData'][$field] = true;
+ $this->attachmentData[$field] = $val;
+ }
+
+
+ public function getLastPageIndexSettingKey() {
+ if (!$this->isFileAttachment()) {
+ throw new Exception("getLastPageIndexSettingKey() can only be called on file attachments");
+ }
+ $libraryType = Zotero_Libraries::getType($this->libraryID);
+ $key = 'lastPageIndex_';
+ switch ($libraryType) {
+ case 'user':
+ $key .= 'u';
+ break;
+
+ case 'group':
+ $key .= 'g' . Zotero_Libraries::getLibraryTypeID($this->libraryID);
+ break;
+
+ default:
+ throw new Exception("Can't get last page index key for $libraryType item");
+ }
+ $key .= "_" . $this->key;
+ return $key;
+ }
+
+
+ /**
+ * Returns an array of attachment itemIDs that have this item as a source,
+ * or FALSE if none
+ **/
+ public function getAttachments() {
+ if ($this->isAttachment()) {
+ throw new Exception("getAttachments() cannot be called on attachment items");
+ }
+
+ if (!$this->id) {
+ return false;
+ }
+
+ $sql = "SELECT itemID FROM items NATURAL JOIN itemAttachments WHERE sourceItemID=?";
+
+ // TODO: reimplement sorting by title using values from MongoDB?
+
+ /*
+ if (Zotero.Prefs.get('sortAttachmentsChronologically')) {
+ sql += " ORDER BY dateAdded";
+ return Zotero.DB.columnQuery(sql, this.id);
+ }
+ */
+
+ $itemIDs = Zotero_DB::columnQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+ if (!$itemIDs) {
+ return array();
+ }
+ return $itemIDs;
+ }
+
+
+ /**
+ * Looks for attachment in the following order: oldest PDF attachment matching parent URL,
+ * oldest non-PDF attachment matching parent URL, oldest PDF attachment not matching URL,
+ * old non-PDF attachment not matching URL
+ *
+ * @return {Zotero.Item|FALSE} - Attachment item or FALSE if none
+ */
+ public function getBestAttachment() {
+ if (!$this->isRegularItem()) {
+ throw new Exception("getBestAttachment() can only be called on regular items");
+ }
+ $attachments = $this->getBestAttachments();
+ return $attachments ? $attachments[0] : false;
+ }
+
+
+ /**
+ * Looks for attachment in the following order: oldest PDF attachment matching parent URL,
+ * oldest PDF attachment not matching parent URL, oldest non-PDF attachment matching parent URL,
+ * old non-PDF attachment not matching parent URL
+ *
+ * Unlike the client, this doesn't include linked-file attachments.
+ *
+ * @return {Zotero.Item[]} - An array of Zotero items
+ */
+ public function getBestAttachments() {
+ if (!$this->isRegularItem()) {
+ throw new Exception("getBestAttachments() can only be called on regular items");
+ }
+
+ $url = $this->getField('url', false, false, true);
+ $urlFieldID = Zotero_ItemFields::getID('url');
+ $linkedURLLinkMode = Zotero_Attachments::linkModeNameToNumber('linked_url') + 1;
+ $linkedFileLinkMode = Zotero_Attachments::linkModeNameToNumber('linked_file') + 1;
+
+ $sql = "SELECT IA.itemID FROM itemAttachments IA NATURAL JOIN items I "
+ . "LEFT JOIN itemData ID ON (IA.itemID=ID.itemID AND fieldID=$urlFieldID) "
+ . "WHERE sourceItemID=? AND linkMode NOT IN ($linkedURLLinkMode, $linkedFileLinkMode) "
+ . "AND IA.itemID NOT IN (SELECT itemID FROM deletedItems) "
+ . "ORDER BY mimeType='application/pdf' DESC, value=? DESC, dateAdded ASC";
+ $itemIDs = Zotero_DB::columnQuery(
+ $sql,
+ [
+ $this->id,
+ $url
+ ],
+ Zotero_Shards::getByLibraryID($this->libraryID)
+ );
+ return $itemIDs ? Zotero_Items::get($this->libraryID, $itemIDs) : [];
+ }
+
+ //
+ // Annotation methods
+ //
+ private function getAnnotationField($field) {
+ if (!$this->isAnnotation()) {
+ throw new Exception("getAnnotationField() can only called on annotation items");
+ }
+
+ $fieldFull = 'annotation' . ucwords($field);
+
+ if ($this->annotationData[$field] !== null) {
+ return $this->annotationData[$field];
+ }
+
+ if (!$this->id) {
+ return null;
+ }
+
+ $sql = "SELECT $field FROM itemAnnotations WHERE itemID=?";
+ $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
+ $value = Zotero_DB::valueQueryFromStatement($stmt, $this->id);
+ if (!$value) {
+ $value = '';
+ }
+
+ switch ($field) {
+ case 'color':
+ // Add '#' to hex color
+ if (preg_match('/^[0-9a-z]{6}$/', $value)) {
+ $value = '#' . $value;
+ }
+ break;
+
+ /*case 'position':
+ $value = json_decode($value, true);
+ break;*/
+ }
+
+ $this->annotationData[$field] = $value;
+ return $value;
+ }
+
+ private function setAnnotationField($field, $val) {
+ $fieldFull = 'annotation' . ucwords($field);
+
+ Z_Core::debug("Setting annotation field $field to " . json_encode($val));
+ switch ($field) {
+ case 'type':
+ switch ($val) {
+ case 'highlight':
+ case 'note':
+ case 'image':
+ case 'ink':
+ break;
+
+ default:
+ throw new Exception(
+ "annotationType must be 'highlight', 'note', 'image', or 'ink'",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ break;
+
+ case 'color':
+ if ($val && !preg_match('/^#[0-9a-z]{6}$/', $val)) {
+ throw new Exception(
+ "annotationColor must be a hex color (e.g., '#FF0000')",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ break;
+
+ case 'sortIndex':
+ if (!preg_match('/^\d{5}\|\d{6}\|\d{5}$/', $val)) {
+ throw new Exception("Invalid sortIndex '$val'", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+
+ case 'authorName':
+ case 'text':
+ case 'comment':
+ case 'pageLabel':
+ case 'position':
+ if (!is_string($val)) {
+ throw new Exception("$fieldFull must be a string", Z_ERROR_INVALID_INPUT);
+ }
+ if (!$val) {
+ $val = '';
+ }
+ // Check annotationText length
+ if ($field == 'text') {
+ $val = mb_substr($val, 0, Zotero_Items::$maxAnnotationTextLength);
+ }
+ // Check annotationPageLabel length
+ if ($field == 'pageLabel' && strlen($val) > Zotero_Items::$maxAnnotationPageLabelLength) {
+ throw new Exception(
+ // TODO: Restore once output isn't HTML-encoded
+ //"Annotation page label '" . mb_substr($val, 0, 50) . "…' is too long",
+ "Annotation page label is too long for attachment " . $this->getSourceKey(),
+ // TEMP: Return 400 until client can handle a specified annotation item,
+ // either by selecting the parent attachment or displaying annotation items
+ // in the items list
+ //Z_ERROR_FIELD_TOO_LONG
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ // Check annotationPosition length
+ if ($field == 'position' && strlen($val) > Zotero_Items::$maxAnnotationPositionLength) {
+ throw new Exception(
+ // TODO: Restore once output isn't HTML-encoded
+ //"Annotation position '" . mb_substr($val, 0, 50) . "…' is too long",
+ "Annotation position is too long for attachment " . $this->getSourceKey(),
+ // TEMP: Return 400 until client can handle a specified annotation item,
+ // either by selecting the parent attachment or displaying annotation items
+ // in the items list
+ //Z_ERROR_FIELD_TOO_LONG
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ break;
+
+ default:
+ trigger_error("Invalid annotation field '$field'", E_USER_ERROR);
+ }
+
+ if (!$this->isAnnotation()) {
+ trigger_error("$fieldFull can only be set for annotation items", E_USER_ERROR);
+ }
+
+ $current = $this->$fieldFull;
+
+ if ($val === $current) {
+ return;
+ }
+
+ if ($field == 'type') {
+ if ($current && $val !== $current) {
+ throw new Exception(
+ "Cannot change existing annotationType for item $this->libraryKey",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ }
+
+ $this->changed['annotationData'][$field] = true;
+ $this->annotationData[$field] = $val;
+ }
+
+
+ /**
+ * Get the first line of the annotation for display in the items list
+ *
+ * Note: Annotation titles can also come from Zotero.Items.cacheFields()!
+ * TODO: Implement caching
+ *
+ * @return {String}
+ */
+ public function getAnnotationTitle() {
+ if (!$this->isAnnotation()) {
+ throw ("getAnnotationTitle() can only be called on annotations");
+ }
+
+ if ($this->annotationTitle !== null) {
+ return $this->annotationTitle;
+ }
+
+ if (!$this->id) {
+ return '';
+ }
+
+ $sql = "SELECT COALESCE(NULLIF(text, ''), comment) FROM itemAnnotations WHERE itemID=?";
+ $title = Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+
+ $this->annotationTitle = $title ? $title : '';
+ return $this->annotationTitle;
+ }
+
+
+ /**
+ * Returns an array of annotation itemIDs that have this item as a parent or FALSE if none
+ */
+ public function getAnnotations() {
+ if (!$this->isFileAttachment()) {
+ throw new Exception("getAnnotations() can only be called on file attachments");
+ }
+
+ if (!$this->id) {
+ return false;
+ }
+
+ $sql = "SELECT itemID FROM items NATURAL JOIN itemAnnotations WHERE parentItemID=?";
+ $itemIDs = Zotero_DB::columnQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+ if (!$itemIDs) {
+ return [];
+ }
+ return $itemIDs;
+ }
+
+
+ /**
+ * Returns number of child annotations of an attachment
+ *
+ * @param {Boolean} includeTrashed Include trashed child items in count
+ * @return {Integer}
+ */
+ public function numAnnotations($includeTrashed=false) {
+ if (!$this->isFileAttachment()) {
+ throw new Exception("numAnnotations() can only be called on file attachments");
+ }
+
+ if (!$this->id) {
+ return 0;
+ }
+
+ if (!isset($this->numAnnotations)) {
+ $sql = "SELECT COUNT(*) FROM itemAnnotations WHERE parentItemID=?";
+ $this->numAnnotations = (int) Zotero_DB::valueQuery(
+ $sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)
+ );
+ }
+
+ $deleted = 0;
+ if ($includeTrashed) {
+ $sql = "SELECT COUNT(*) FROM itemAnnotations WHERE parentItemID=? AND
+ itemID IN (SELECT itemID FROM deletedItems)";
+ $deleted = (int) Zotero_DB::valueQuery(
+ $sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)
+ );
+ }
+
+ return $this->numAnnotations + $deleted;
+ }
+
+
+ public function incrementAnnotationCount() {
+ $this->numAnnotations++;
+ }
+
+
+ public function decrementAnnotationCount() {
+ $this->numAnnotations--;
+ }
+
+
+ //
+ // Methods dealing with tags
+ //
+ // save() is not required for tag functions
+ //
+ public function numTags() {
+ if (!$this->id) {
+ return 0;
+ }
+
+ $sql = "SELECT COUNT(*) FROM itemTags WHERE itemID=?";
+ return (int) Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+ }
+
+
+ /**
+ * Returns all tags assigned to an item
+ *
+ * @return array Array of Zotero.Tag objects
+ */
+ public function getTags($asIDs=false) {
+ if (!$this->id) {
+ return array();
+ }
+
+ $sql = "SELECT tagID FROM tags JOIN itemTags USING (tagID)
+ WHERE itemID=? ORDER BY name";
+ $tagIDs = Zotero_DB::columnQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+ if (!$tagIDs) {
+ return array();
+ }
+
+ if ($asIDs) {
+ return $tagIDs;
+ }
+
+ $tagObjs = array();
+ foreach ($tagIDs as $tagID) {
+ $tag = Zotero_Tags::get($this->libraryID, $tagID, true);
+ $tagObjs[] = $tag;
+ }
+ return $tagObjs;
+ }
+
+
+ /**
+ * Updates the tags associated with an item
+ *
+ * @param array $newTags Array of objects with properties 'tag' and 'type'
+ */
+ public function setTags($newTags) {
+ if (!$this->loaded['tags']) {
+ $this->loadTags();
+ }
+
+ // Ignore empty tags
+ $newTags = array_filter($newTags, function ($tag) {
+ if (is_string($tag)) {
+ return trim($tag) !== "";
+ }
+ return trim($tag->tag) !== "";
+ });
+
+ if (!$newTags && !$this->tags) {
+ return false;
+ }
+
+ $this->storePreviousData('tags');
+ $this->tags = [];
+ foreach ($newTags as $newTag) {
+ $obj = new stdClass;
+ // Allow the passed array to contain either strings or objects
+ if (is_string($newTag)) {
+ $obj->name = trim($newTag);
+ $obj->type = 0;
+ }
+ else {
+ $obj->name = trim($newTag->tag);
+ $obj->type = (int) isset($newTag->type) ? $newTag->type : 0;
+ }
+ $this->tags[] = $obj;
+ }
+ $this->changed['tags'] = true;
+ }
+
+
+ //
+ // Methods dealing with collections
+ //
+ public function numCollections() {
+ if (!$this->loaded['collections']) {
+ $this->loadCollections();
+ }
+ return sizeOf($this->collections);
+ }
+
+
+ /**
+ * Returns all collections the item is in
+ *
+ * @param boolean [$asKeys=false] Return collection keys instead of collection objects
+ * @return array Array of Zotero_Collection objects, or keys if $asKeys=true
+ */
+ public function getCollections($asKeys=false) {
+ if (!$this->loaded['collections']) {
+ $this->loadCollections();
+ }
+ if ($asKeys) {
+ return $this->collections;
+ }
+ return array_map(function ($key) {
+ return Zotero_Collections::getByLibraryAndKey(
+ $this->libraryID, $key, true
+ );
+ }, $this->collections);
+ }
+
+
+ /**
+ * Updates the collections an item is in
+ *
+ * @param array $newCollections Array of new collection keys to set
+ */
+ public function setCollections($collectionKeys=[]) {
+ if (!$this->loaded['collections']) {
+ $this->loadCollections();
+ }
+
+ if ((!$this->collections && !$collectionKeys) ||
+ (!Zotero_Utilities::arrayDiffFast($this->collections, $collectionKeys) &&
+ !Zotero_Utilities::arrayDiffFast($collectionKeys, $this->collections))) {
+ Z_Core::debug("Collections have not changed for item $this->id");
+ return;
+ }
+
+ $this->storePreviousData('collections');
+ $this->collections = array_unique($collectionKeys);
+ $this->changed['collections'] = true;
+ }
+
+
+ public function toHTML(bool $asSimpleXML, $requestParams) {
+ $html = new SimpleXMLElement('');
+
+ /*
+ // Title
+ $tr = $html->addChild('tr');
+ $tr->addAttribute('class', 'title');
+ $tr->addChild('th', Zotero_ItemFields::getLocalizedString('title'));
+ $tr->addChild('td', htmlspecialchars($item->getDisplayTitle(true)));
+ */
+
+ // Item type
+ Zotero_Atom::addHTMLRow(
+ $html,
+ "itemType",
+ Zotero_ItemFields::getLocalizedString('itemType'),
+ Zotero_ItemTypes::getLocalizedString($this->itemTypeID)
+ );
+
+ // Creators
+ $creators = $this->getCreators();
+ if ($creators) {
+ $displayText = '';
+ foreach ($creators as $creator) {
+ // Two fields
+ if ($creator['ref']->fieldMode == 0) {
+ $displayText = $creator['ref']->firstName . ' ' . $creator['ref']->lastName;
+ }
+ // Single field
+ else if ($creator['ref']->fieldMode == 1) {
+ $displayText = $creator['ref']->lastName;
+ }
+ else {
+ // TODO
+ }
+
+ Zotero_Atom::addHTMLRow(
+ $html,
+ "creator",
+ Zotero_CreatorTypes::getLocalizedString($creator['creatorTypeID']),
+ trim($displayText)
+ );
+ }
+ }
+
+ $primaryFields = array();
+ $fields = array_merge($primaryFields, $this->getUsedFields());
+
+ foreach ($fields as $field) {
+ if (Zotero_Items::isPrimaryField($field)) {
+ $fieldName = $field;
+ }
+ else {
+ $fieldName = Zotero_ItemFields::getName($field);
+ }
+
+ // Skip certain fields
+ switch ($fieldName) {
+ case '':
+ case 'userID':
+ case 'libraryID':
+ case 'key':
+ case 'itemTypeID':
+ case 'itemID':
+ case 'title':
+ case 'serverDateModified':
+ case 'version':
+ continue 2;
+ }
+
+ if (Zotero_ItemFields::isFieldOfBase($fieldName, 'title')) {
+ continue;
+ }
+
+ $localizedFieldName = Zotero_ItemFields::getLocalizedString($field);
+
+ $value = $this->getField($field);
+ $value = trim($value);
+
+ // Skip empty fields
+ if (!$value) {
+ continue;
+ }
+
+ $fieldText = '';
+
+ // Shorten long URLs manually until Firefox wraps at ?
+ // (like Safari) or supports the CSS3 word-wrap property
+ if (false && preg_match("'https?://'", $value)) {
+ $fieldText = $value;
+
+ $firstSpace = strpos($value, ' ');
+ // Break up long uninterrupted string
+ if (($firstSpace === false && strlen($value) > 29) || $firstSpace > 29) {
+ $stripped = false;
+
+ /*
+ // Strip query string for sites we know don't need it
+ for each(var re in _noQueryStringSites) {
+ if (re.test($field)){
+ var pos = $field.indexOf('?');
+ if (pos != -1) {
+ fieldText = $field.substr(0, pos);
+ stripped = true;
+ }
+ break;
+ }
+ }
+ */
+
+ if (!$stripped) {
+ // Add a line-break after the ? of long URLs
+ //$fieldText = str_replace($field.replace('?', "?");
+
+ // Strip query string variables from the end while the
+ // query string is longer than the main part
+ $pos = strpos($fieldText, '?');
+ if ($pos !== false) {
+ while ($pos < (strlen($fieldText) / 2)) {
+ $lastAmp = strrpos($fieldText, '&');
+ if ($lastAmp === false) {
+ break;
+ }
+ $fieldText = substr($fieldText, 0, $lastAmp);
+ $shortened = true;
+ }
+ // Append '&...' to the end
+ if ($shortened) {
+ $fieldText .= "&…";
+ }
+ }
+ }
+ }
+
+ if ($field == 'url') {
+ $linkContainer = new SimpleXMLElement("");
+ $linkContainer->a = $value;
+ $linkContainer->a['href'] = $fieldText;
+ }
+ }
+ // Remove SQL date from multipart dates
+ // (e.g. '2006-00-00 Summer 2006' becomes 'Summer 2006')
+ else if ($fieldName == 'date') {
+ $fieldText = $value;
+ }
+ // Convert dates to local format
+ else if ($fieldName == 'accessDate' || $fieldName == 'dateAdded' || $fieldName == 'dateModified') {
+ //$date = Zotero.Date.sqlToDate($field, true)
+ $date = $value;
+ //fieldText = escapeXML(date.toLocaleString());
+ $fieldText = $date;
+ }
+ else {
+ $fieldText = $value;
+ }
+
+ if (isset($linkContainer)) {
+ $tr = Zotero_Atom::addHTMLRow($html, $fieldName, $localizedFieldName, "", true);
+
+ $tdNode = dom_import_simplexml($tr->td);
+ $linkNode = dom_import_simplexml($linkContainer->a);
+ $importedNode = $tdNode->ownerDocument->importNode($linkNode, true);
+ $tdNode->appendChild($importedNode);
+ unset($linkContainer);
+ }
+ else {
+ Zotero_Atom::addHTMLRow($html, $fieldName, $localizedFieldName, $fieldText);
+ }
+ }
+
+ if ($this->isNote() || $this->isAttachment()) {
+ $note = $this->getNote(true);
+ if ($note) {
+ $tr = Zotero_Atom::addHTMLRow($html, "note", "Note", "", true);
+
+ try {
+ $noteXML = @new SimpleXMLElement("| " . $note . " | ");
+ $trNode = dom_import_simplexml($tr);
+ $tdNode = $trNode->getElementsByTagName("td")->item(0);
+ $noteNode = dom_import_simplexml($noteXML);
+ $importedNode = $trNode->ownerDocument->importNode($noteNode, true);
+ $trNode->replaceChild($importedNode, $tdNode);
+ unset($noteXML);
+ }
+ catch (Exception $e) {
+ // Store non-HTML notes as
+ $tr->td->pre = $note;
+ }
+ }
+ }
+
+ if ($this->isAttachment()) {
+ Zotero_Atom::addHTMLRow(
+ $html,
+ "linkMode",
+ "Link Mode",
+ // TODO: Stop returning number
+ Zotero_Attachments::linkModeNameToNumber($this->attachmentLinkMode)
+ );
+ Zotero_Atom::addHTMLRow($html, "mimeType", "MIME Type", $this->attachmentMIMEType);
+ Zotero_Atom::addHTMLRow($html, "charset", "Character Set", $this->attachmentCharset);
+
+ // TODO: get from a constant
+ /*if ($this->attachmentLinkMode != 3) {
+ $doc->addField('path', $this->attachmentPath);
+ }*/
+ }
+
+ if ($this->getDeleted()) {
+ Zotero_Atom::addHTMLRow($html, "deleted", "Deleted", "Yes");
+ }
+
+ if (!$requestParams['publications'] && $this->getPublications() ) {
+ Zotero_Atom::addHTMLRow($html, "publications", "In My Publications", "Yes");
+ }
+
+ if ($asSimpleXML) {
+ return $html;
+ }
+
+ return str_replace('', '', $html->asXML());
+ }
+
+
+ /**
+ * Get some uncached properties used by JSON and Atom
+ */
+ public function getUncachedResponseProps($requestParams, Zotero_Permissions $permissions) {
+ $parent = $this->getSource();
+ $isRegularItem = !$parent && $this->isRegularItem();
+ $bestAttachmentDetails = false;
+ $downloadDetails = false;
+ if ($isRegularItem) {
+ if ($requestParams['publications']) {
+ $numChildren = $this->numPublicationsChildren();
+ }
+ else if ($permissions->canAccess($this->libraryID, 'notes')) {
+ $numChildren = $this->numChildren();
+ }
+ else {
+ $numChildren = $this->numAttachments();
+ }
+
+ if ($requestParams['publications'] || $permissions->canAccess($this->libraryID, 'files')) {
+ $bestAttachment = $this->getBestAttachment();
+ if ($bestAttachment) {
+ $dd = Zotero_Storage::getDownloadDetails($bestAttachment);
+ if ($dd) {
+ $bestAttachmentDetails = [
+ 'key' => Zotero_API::getItemURI($bestAttachment),
+ 'type' => 'application/json',
+ 'attachmentType' => $bestAttachment->attachmentContentType
+ ];
+ $bestAttachmentDetails['attachmentSize'] = $dd['size'] ?? false;
+ }
+ }
+ }
+ }
+ else {
+ if ($this->isNote()
+ // Annotations depend on note permissions
+ || ($this->isPDFAttachment() && $permissions->canAccess($this->libraryID, 'notes'))) {
+ $numChildren = $this->numChildren();
+ }
+ else {
+ $numChildren = false;
+ }
+
+ if ($requestParams['publications'] || $permissions->canAccess($this->libraryID, 'files')) {
+ $downloadDetails = Zotero_Storage::getDownloadDetails($this);
+ // Link to publications download URL in My Publications
+ if ($downloadDetails && $requestParams['publications']) {
+ $downloadDetails['url'] = str_replace("/items/", "/publications/items/", $downloadDetails['url']);
+ }
+ }
+ }
+
+ return [
+ "bestAttachmentDetails" => $bestAttachmentDetails,
+ "numChildren" => $numChildren,
+ "downloadDetails" => $downloadDetails
+ ];
+ }
+
+
+ public function toResponseJSON(array $requestParams, Zotero_Permissions $permissions, $sharedData=null) {
+ $t = microtime(true);
+
+ if (!$this->loaded['primaryData']) {
+ $this->loadPrimaryData();
+ }
+ if (!$this->loaded['itemData']) {
+ $this->loadItemData();
+ }
+
+ // Uncached stuff or parts of the cache key
+ $version = $this->version;
+ $parent = $this->getSource();
+ $isRegularItem = !$parent && $this->isRegularItem();
+ $isPublications = $requestParams['publications'];
+
+ $props = $this->getUncachedResponseProps($requestParams, $permissions);
+ $bestAttachmentDetails = $props['bestAttachmentDetails'];
+ $downloadDetails = $props['downloadDetails'];
+ $numChildren = $props['numChildren'];
+
+ $libraryType = Zotero_Libraries::getType($this->libraryID);
+
+ // Any query parameters that have an effect on an individual item's response JSON
+ // need to be added here
+ $allowedParams = [
+ 'include',
+ 'style',
+ 'css',
+ 'linkwrap',
+ 'publications'
+ ];
+ $cachedParams = Z_Array::filterKeys($requestParams, $allowedParams);
+
+ $cacheVersion = 1;
+ $cacheKey = "jsonEntry_" . $this->libraryID . "/" . $this->id . "_"
+ . md5(
+ $version
+ . json_encode($cachedParams)
+ . ($bestAttachmentDetails ? json_encode($bestAttachmentDetails) : '')
+ . ($downloadDetails ? json_encode($downloadDetails) : '')
+ // For groups, include the group WWW URL, which can change
+ . ($libraryType == 'group' ? Zotero_URI::getItemURI($this, true) : '')
+ )
+ . "_" . $requestParams['v']
+ // For code-based changes
+ . "_" . $cacheVersion
+ // For data-based changes
+ . (isset(Z_CONFIG::$CACHE_VERSION_RESPONSE_JSON_ITEM)
+ ? "_" . Z_CONFIG::$CACHE_VERSION_RESPONSE_JSON_ITEM
+ : "")
+ // If there's bib content, include the bib cache version
+ . ((in_array('bib', $requestParams['include'])
+ && isset(Z_CONFIG::$CACHE_VERSION_BIB))
+ ? "_" . Z_CONFIG::$CACHE_VERSION_BIB
+ : "");
+
+ $cached = Z_Core::$MC->get($cacheKey);
+ if (false && $cached) {
+ if ($isRegularItem
+ || $this->isNote()
+ || $this->isPDFAttachment()) {
+ $cached['meta']->numChildren = $numChildren;
+ }
+
+ StatsD::timing("api.items.itemToResponseJSON.cached", (microtime(true) - $t) * 1000);
+ StatsD::increment("memcached.items.itemToResponseJSON.hit");
+
+ // Skip the cache every 10 times for now, to ensure cache sanity
+ if (!Z_Core::probability(10)) {
+ return $cached;
+ }
+ }
+
+
+ $json = [
+ 'key' => $this->key,
+ 'version' => $version,
+ 'library' => Zotero_Libraries::toJSON($this->libraryID)
+ ];
+
+ $url = Zotero_API::getItemURI($this);
+ if ($isPublications) {
+ $url = str_replace("/items/", "/publications/items/", $url);
+ }
+ $json['links'] = [
+ 'self' => [
+ 'href' => $url,
+ 'type' => 'application/json'
+ ],
+ 'alternate' => [
+ 'href' => Zotero_URI::getItemURI($this, true),
+ 'type' => 'text/html'
+ ]
+ ];
+
+ if ($bestAttachmentDetails) {
+ $details = $bestAttachmentDetails;
+ $json['links']['attachment'] = [
+ 'href' => $details['key']
+ ];
+ if (!empty($details['type'])) {
+ $json['links']['attachment']['type'] = $details['type'];
+ }
+ if (!empty($details['attachmentType'])) {
+ $json['links']['attachment']['attachmentType'] = $details['attachmentType'];
+ }
+ if (!empty($details['attachmentSize'])) {
+ $json['links']['attachment']['attachmentSize'] = $details['attachmentSize'];
+ }
+ }
+
+ if ($parent) {
+ $parentItem = Zotero_Items::get($this->libraryID, $parent);
+ $url = Zotero_API::getItemURI($parentItem);
+ if ($isPublications) {
+ $url = str_replace("/items/", "/publications/items/", $url);
+ }
+ $json['links']['up'] = [
+ 'href' => $url,
+ 'type' => 'application/json'
+ ];
+ }
+
+ // If appropriate permissions and the file is stored in ZFS, get file request link
+ if ($downloadDetails) {
+ $details = $downloadDetails;
+ $type = $this->attachmentMIMEType;
+ if ($type) {
+ $json['links']['enclosure'] = [
+ 'type' => $type
+ ];
+ }
+ $json['links']['enclosure']['href'] = $details['url'];
+ if (!empty($details['filename'])) {
+ $json['links']['enclosure']['title'] = $details['filename'];
+ }
+ if (isset($details['size'])) {
+ $json['links']['enclosure']['length'] = $details['size'];
+ }
+ }
+
+ // 'meta'
+ $json['meta'] = new stdClass;
+
+ if (Zotero_Libraries::getType($this->libraryID) == 'group') {
+ $createdByUserID = $this->createdByUserID;
+ $lastModifiedByUserID = $this->lastModifiedByUserID;
+
+ if ($createdByUserID) {
+ try {
+ $json['meta']->createdByUser = Zotero_Users::toJSON($createdByUserID);
+ }
+ // If user no longer exists, this will fail
+ catch (Exception $e) {
+ if (Zotero_Users::exists($createdByUserID)) {
+ throw $e;
+ }
+ }
+ }
+
+ if ($lastModifiedByUserID && $lastModifiedByUserID != $createdByUserID) {
+ try {
+ $json['meta']->lastModifiedByUser = Zotero_Users::toJSON($lastModifiedByUserID);
+ }
+ // If user no longer exists, this will fail
+ catch (Exception $e) {
+ if (Zotero_Users::exists($lastModifiedByUserID)) {
+ throw $e;
+ }
+ }
+ }
+ }
+
+ if ($isRegularItem) {
+ $val = $this->getCreatorSummary();
+ if ($val !== '') {
+ $json['meta']->creatorSummary = $val;
+ }
+
+ $val = $this->getField('date', true, true, true);
+ if ($val !== '') {
+ $sqlDate = Zotero_Date::multipartToSQL($val);
+ if (substr($sqlDate, 0, 4) !== '0000') {
+ $json['meta']->parsedDate = Zotero_Date::sqlToISO8601($sqlDate);
+ }
+ }
+ }
+
+ if ($isRegularItem
+ || $this->isNote()
+ || $this->isPDFAttachment()) {
+ $json['meta']->numChildren = $numChildren;
+ }
+
+ // 'include'
+ $include = $requestParams['include'];
+
+ foreach ($include as $type) {
+ if ($type == 'html') {
+ $json[$type] = trim($this->toHTML(false, $requestParams));
+ }
+ else if ($type == 'citation') {
+ if (isset($sharedData[$type][$this->libraryID . "/" . $this->key])) {
+ $html = $sharedData[$type][$this->libraryID . "/" . $this->key];
+ }
+ else {
+ if ($sharedData !== null) {
+ //error_log("Citation not found in sharedData -- retrieving individually");
+ }
+ $html = Zotero_Cite::getCitationFromCiteServer($this, $requestParams);
+ }
+ $json[$type] = $html;
+ }
+ else if ($type == 'bib') {
+ if (isset($sharedData[$type][$this->libraryID . "/" . $this->key])) {
+ $html = $sharedData[$type][$this->libraryID . "/" . $this->key];
+ }
+ else {
+ if ($sharedData !== null) {
+ //error_log("Bibliography not found in sharedData -- retrieving individually");
+ }
+ $html = Zotero_Cite::getBibliographyFromCitationServer([$this], $requestParams);
+
+ // Strip prolog
+ $html = preg_replace('/^<\?xml.+\n/', "", $html);
+ $html = trim($html);
+ }
+ $json[$type] = $html;
+ }
+ else if ($type == 'data') {
+ $json[$type] = $this->toJSON(true, $requestParams, true);
+ }
+ else if ($type == 'csljson') {
+ $json[$type] = $this->toCSLItem();
+ }
+ else if (in_array($type, Zotero_Translate::$exportFormats)) {
+ $exportParams = $requestParams;
+ $exportParams['format'] = $type;
+ $export = Zotero_Translate::doExport([$this], $exportParams);
+ $json[$type] = $export['body'];
+ unset($export);
+ }
+ }
+
+ // TEMP
+ if ($cached) {
+ $cachedStr = Zotero_Utilities::formatJSON($cached);
+ $uncachedStr = Zotero_Utilities::formatJSON($json);
+ if ($cachedStr != $uncachedStr) {
+ error_log("Cached JSON item entry does not match");
+ error_log(" Cached: " . $cachedStr);
+ error_log("Uncached: " . $uncachedStr);
+
+ //Z_Core::$MC->set($cacheKey, $uncached, 3600); // 1 hour for now
+ }
+ }
+ else {
+ /*Z_Core::$MC->set($cacheKey, $json, 10);
+ StatsD::timing("api.items.itemToResponseJSON.uncached", (microtime(true) - $t) * 1000);
+ StatsD::increment("memcached.items.itemToResponseJSON.miss");*/
+ }
+
+ return $json;
+ }
+
+
+ public function toJSON($asArray=false, $requestParams=array(), $includeEmpty=false, $unformattedFields=false) {
+ $isPublications = !empty($requestParams['publications']);
+
+ if ($this->_id || $this->_key) {
+ if ($this->_version) {
+ // TODO: Check memcache and return if present
+ }
+
+ if (!$this->loaded['primaryData']) {
+ $this->loadPrimaryData();
+ }
+ if (!$this->loaded['itemData']) {
+ $this->loadItemData();
+ }
+ }
+
+ if (!isset($requestParams['v'])) {
+ $requestParams['v'] = 3;
+ }
+
+ $regularItem = $this->isRegularItem();
+ $embeddedImage = $this->isEmbeddedImageAttachment();
+
+ $arr = array();
+ if ($requestParams['v'] >= 2) {
+ if ($requestParams['v'] >= 3) {
+ $arr['key'] = $this->key;
+ $arr['version'] = $this->version;
+ }
+ else {
+ $arr['itemKey'] = $this->key;
+ $arr['itemVersion'] = $this->version;
+ }
+
+ $key = $this->getSourceKey();
+ if ($key) {
+ $arr['parentItem'] = $key;
+ }
+ }
+ $arr['itemType'] = Zotero_ItemTypes::getName($this->itemTypeID);
+
+ if ($this->isAttachment()) {
+ $arr['linkMode'] = $this->attachmentLinkMode;
+ }
+
+ // For regular items, show title and creators first
+ if ($regularItem) {
+ // Get 'title' or the equivalent base-mapped field
+ $titleFieldID = Zotero_ItemFields::getFieldIDFromTypeAndBase($this->itemTypeID, 'title');
+ $titleFieldName = Zotero_ItemFields::getName($titleFieldID);
+ $value = $this->itemData[$titleFieldID];
+ $isEmpty = ($value !== false && $value !== null && $value !== "");
+ if ($includeEmpty || !$isEmpty) {
+ $arr[$titleFieldName] = $isEmpty ? $value : "";
+ }
+
+ // Creators
+ $arr['creators'] = array();
+ $creators = $this->getCreators();
+ foreach ($creators as $creator) {
+ $c = array();
+ $c['creatorType'] = Zotero_CreatorTypes::getName($creator['creatorTypeID']);
+
+ // Single-field mode
+ if ($creator['ref']->fieldMode == 1) {
+ $c['name'] = $creator['ref']->lastName;
+ }
+ // Two-field mode
+ else {
+ $c['firstName'] = $creator['ref']->firstName;
+ $c['lastName'] = $creator['ref']->lastName;
+ }
+ $arr['creators'][] = $c;
+ }
+ if (!$arr['creators'] && !$includeEmpty) {
+ unset($arr['creators']);
+ }
+ }
+ else {
+ $titleFieldID = false;
+ }
+
+ // Item metadata
+ $fields = array_keys($this->itemData);
+ foreach ($fields as $field) {
+ if ($field == $titleFieldID) {
+ continue;
+ }
+
+ if ($unformattedFields) {
+ $value = $this->itemData[$field];
+ }
+ else {
+ $value = $this->getField($field);
+ }
+
+ if (!$includeEmpty && ($value === false || $value === null && $value === "")) {
+ continue;
+ }
+
+ $fieldName = Zotero_ItemFields::getName($field);
+ // TEMP
+ if ($fieldName == 'versionNumber') {
+ if ($requestParams['v'] < 3) {
+ $fieldName = 'version';
+ }
+ }
+ else if ($fieldName == 'accessDate') {
+ if ($requestParams['v'] >= 3 && $value !== false && $value !== null && $value !== "") {
+ $value = Zotero_Date::sqlToISO8601($value);
+ }
+ }
+ $arr[$fieldName] = ($value !== false && $value !== null && $value !== "") ? $value : "";
+ }
+
+ if ($embeddedImage) {
+ unset($arr['title'], $arr['url'], $arr['accessDate']);
+ }
+
+ // Embedded note for notes and attachments
+ if ($this->isNote() || ($this->isAttachment() && !$embeddedImage)) {
+ // Use sanitized version
+ $arr['note'] = $this->getNote(true);
+ }
+
+ if ($this->isAttachment()) {
+ $arr['linkMode'] = $this->attachmentLinkMode;
+
+ $val = $this->attachmentMIMEType;
+ if ($includeEmpty || ($val !== false && $val !== null && $val !== "")) {
+ $arr['contentType'] = $val;
+ }
+
+ if (!$embeddedImage) {
+ $val = $this->attachmentCharset;
+ if ($includeEmpty || $val) {
+ if ($val) {
+ // TODO: Move to CharacterSets::getName() after classic sync removal
+ $val = Zotero_CharacterSets::toCanonical($val);
+ }
+ $arr['charset'] = $val;
+ }
+ }
+
+ if ($this->isStoredFileAttachment()) {
+ $arr['filename'] = $this->attachmentFilename;
+
+ $val = $this->attachmentStorageHash;
+ if ($includeEmpty || $val) {
+ $arr['md5'] = $val;
+ }
+
+ $val = $this->attachmentStorageModTime;
+ if ($includeEmpty || $val) {
+ $arr['mtime'] = $val;
+ }
+ }
+ else if ($arr['linkMode'] == 'linked_file') {
+ $val = $this->attachmentPath;
+ if ($includeEmpty || $val) {
+ $arr['path'] = Zotero_Attachments::decodeRelativeDescriptorString($val);
+ }
+ }
+ }
+
+ if ($this->isAnnotation()) {
+ $props = ['type', 'authorName', 'text', 'comment', 'color', 'pageLabel', 'sortIndex', 'position'];
+ foreach ($props as $prop) {
+ if ($prop == 'authorName' && $this->annotationAuthorName === '') {
+ continue;
+ }
+ if ($prop == 'text' && $this->annotationType != 'highlight') {
+ continue;
+ }
+ $fullProp = 'annotation' . ucwords($prop);
+ $arr[$fullProp] = $this->$fullProp;
+ }
+ }
+
+ // Non-field properties, which don't get shown for publications endpoints
+ if (!$isPublications) {
+ if ($this->getDeleted()) {
+ // TODO: Use true/false in APIv4
+ $arr['deleted'] = 1;
+ }
+
+ if ($this->getPublications()) {
+ $arr['inPublications'] = true;
+ }
+
+ if (!$embeddedImage) {
+ // Tags
+ $arr['tags'] = array();
+ $tags = $this->getTags();
+ if ($tags) {
+ foreach ($tags as $tag) {
+ // Skip empty tags that are still in the database
+ if (trim($tag->name) === "") {
+ continue;
+ }
+ $t = array(
+ 'tag' => $tag->name
+ );
+ if ($tag->type != 0) {
+ $t['type'] = $tag->type;
+ }
+ $arr['tags'][] = $t;
+ }
+ }
+
+ if ($requestParams['v'] >= 2) {
+ // Collections
+ if ($this->isTopLevelItem()) {
+ $collections = $this->getCollections(true);
+ $arr['collections'] = $collections;
+ }
+
+ // Relations
+ $arr['relations'] = $this->getRelations();
+ }
+ }
+
+ if ($requestParams['v'] >= 3) {
+ $arr['dateAdded'] = Zotero_Date::sqlToISO8601($this->dateAdded);
+ $arr['dateModified'] = Zotero_Date::sqlToISO8601($this->dateModified);
+ }
+ }
+
+ if ($asArray) {
+ return $arr;
+ }
+
+ // Before v3, additional characters were escaped in the JSON, for unclear reasons
+ $escapeAll = $requestParams['v'] <= 2;
+
+ return Zotero_Utilities::formatJSON($arr, $escapeAll);
+ }
+
+
+ public function toCSLItem() {
+ return Zotero_Cite::retrieveItem($this);
+ }
+
+
+ //
+ //
+ // Private methods
+ //
+ //
+ protected function loadItemData($reload = false) {
+ if ($this->loaded['itemData'] && !$reload) return;
+
+ Z_Core::debug("Loading item data for item $this->id");
+
+ // TODO: remove?
+ if (!$this->id) {
+ trigger_error('Item ID not set before attempting to load data', E_USER_ERROR);
+ }
+
+ if (!is_numeric($this->id)) {
+ trigger_error("Invalid itemID '$this->id'", E_USER_ERROR);
+ }
+
+ if ($this->cacheEnabled) {
+ $cacheVersion = 1;
+ $cacheKey = $this->getCacheKey("itemData",
+ $cacheVersion
+ . isset(Z_CONFIG::$CACHE_VERSION_ITEM_DATA)
+ ? "_" . Z_CONFIG::$CACHE_VERSION_ITEM_DATA
+ : ""
+ );
+ $fields = Z_Core::$MC->get($cacheKey);
+ }
+ else {
+ $fields = false;
+ }
+ if ($fields === false) {
+ $sql = "SELECT fieldID, value FROM itemData WHERE itemID=?";
+ $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
+ $fields = Zotero_DB::queryFromStatement($stmt, $this->id);
+
+ if ($this->cacheEnabled) {
+ Z_Core::$MC->set($cacheKey, $fields ? $fields : array());
+ }
+ }
+
+ $itemTypeFields = Zotero_ItemFields::getItemTypeFields($this->itemTypeID);
+
+ if ($fields) {
+ foreach ($fields as $field) {
+ $this->setField($field['fieldID'], $field['value'], true, true);
+ }
+ }
+
+ // Mark nonexistent fields as loaded
+ if ($itemTypeFields) {
+ foreach($itemTypeFields as $fieldID) {
+ if (is_null($this->itemData[$fieldID])) {
+ $this->itemData[$fieldID] = false;
+ }
+ }
+ }
+
+ $this->loaded['itemData'] = true;
+ }
+
+
+ protected function loadNote($reload = false) {
+ if ($this->loaded['note'] && !$reload) return;
+
+ $this->noteTitle = null;
+ $this->noteText = null;
+
+ // Loaded in getNote()
+ }
+
+
+ private function getNoteHash() {
+ if (!$this->isNote() && !$this->isAttachment()) {
+ trigger_error("getNoteHash() can only be called on notes and attachments", E_USER_ERROR);
+ }
+
+ if (!$this->id) {
+ return '';
+ }
+
+ // Store access time for later garbage collection
+ //$this->noteAccessTime = new Date();
+
+ return Zotero_Notes::getHash($this->libraryID, $this->id);
+ }
+
+
+ protected function loadCreators($reload = false) {
+ if ($this->loaded['creators'] && !$reload) return;
+
+ if (!$this->id) {
+ trigger_error('Item ID not set for item before attempting to load creators', E_USER_ERROR);
+ }
+
+ if (!is_numeric($this->id)) {
+ trigger_error("Invalid itemID '$this->id'", E_USER_ERROR);
+ }
+
+ if ($this->cacheEnabled) {
+ $cacheVersion = 1;
+ $cacheKey = $this->getCacheKey("itemCreators",
+ $cacheVersion
+ . isset(Z_CONFIG::$CACHE_VERSION_ITEM_DATA)
+ ? "_" . Z_CONFIG::$CACHE_VERSION_ITEM_DATA
+ : ""
+ );
+ $creators = Z_Core::$MC->get($cacheKey);
+ }
+ else {
+ $creators = false;
+ }
+ if ($creators === false) {
+ $sql = "SELECT creatorID, creatorTypeID, orderIndex FROM itemCreators
+ WHERE itemID=? ORDER BY orderIndex";
+ $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
+ $creators = Zotero_DB::queryFromStatement($stmt, $this->id);
+
+ if ($this->cacheEnabled) {
+ Z_Core::$MC->set($cacheKey, $creators ? $creators : array());
+ }
+ }
+
+ $this->creators = [];
+ $this->loaded['creators'] = true;
+ $this->clearChanged('creators');
+
+ if (!$creators) {
+ return;
+ }
+
+ foreach ($creators as $creator) {
+ $creatorObj = Zotero_Creators::get($this->libraryID, $creator['creatorID'], true);
+ if (!$creatorObj) {
+ Z_Core::$MC->delete($cacheKey);
+ throw new Exception("Creator {$creator['creatorID']} not found");
+ }
+ $this->creators[$creator['orderIndex']] = array(
+ 'creatorTypeID' => $creator['creatorTypeID'],
+ 'ref' => $creatorObj
+ );
+ }
+ }
+
+
+ protected function loadCollections($reload = false) {
+ if ($this->loaded['collections'] && !$reload) return;
+
+ if (!$this->id) {
+ return;
+ }
+
+ Z_Core::debug("Loading collections for item $this->id");
+
+ $sql = "SELECT C.key FROM collectionItems "
+ . "JOIN collections C USING (collectionID) "
+ . "WHERE itemID=?";
+ $this->collections = Zotero_DB::columnQuery(
+ $sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)
+ );
+ if (!$this->collections) {
+ $this->collections = [];
+ }
+ $this->loaded['collections'] = true;
+ $this->clearChanged('collections');
+ }
+
+
+ protected function loadTags($reload = false) {
+ if ($this->loaded['tags'] && !$reload) return;
+
+ if (!$this->id) {
+ return;
+ }
+
+ Z_Core::debug("Loading tags for item $this->id");
+
+ $sql = "SELECT tagID FROM itemTags JOIN tags USING (tagID) WHERE itemID=?";
+ $tagIDs = Zotero_DB::columnQuery(
+ $sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)
+ );
+ $this->tags = [];
+ if ($tagIDs) {
+ foreach ($tagIDs as $tagID) {
+ $this->tags[] = Zotero_Tags::get($this->libraryID, $tagID, true);
+ }
+ }
+ $this->loaded['tags'] = true;
+ $this->clearChanged('tags');
+ }
+
+
+ /**
+ * @return {array} An array of related item keys
+ */
+ private function getRelatedItems() {
+ $predicate = Zotero_Relations::$relatedItemPredicate;
+
+ $relations = $this->getRelations();
+ if (empty($relations->$predicate)) {
+ return [];
+ }
+
+ $relatedItemURIs = is_string($relations->$predicate)
+ ? [$relations->$predicate]
+ : $relations->$predicate;
+
+ // Pull out object values from related-item relations, turn into items, and pull out keys
+ $keys = [];
+ foreach ($relatedItemURIs as $relatedItemURI) {
+ $item = Zotero_URI::getURIItem($relatedItemURI);
+ if ($item) {
+ $keys[] = $item->key;
+ }
+ }
+ return array_unique($keys);
+ }
+
+
+ /**
+ * @param {array} $itemKeys
+ * @return {Boolean} TRUE if related items were changed, FALSE if not
+ */
+ private function setRelatedItems($itemKeys) {
+ if (!is_array($itemKeys)) {
+ throw new Exception('$itemKeys must be an array');
+ }
+
+ $predicate = Zotero_Relations::$relatedItemPredicate;
+
+ $relations = $this->getRelations();
+ if (!isset($relations->$predicate)) {
+ $relations->$predicate = [];
+ }
+ else if (is_string($relations->$predicate)) {
+ $relations->$predicate = [$relations->$predicate];
+ }
+
+ $currentKeys = array_map(function ($objectURI) {
+ $key = substr($objectURI, -8);
+ return Zotero_ID::isValidKey($key) ? $key : false;
+ }, $relations->$predicate);
+ $currentKeys = array_filter($currentKeys);
+
+ $oldKeys = []; // items being kept
+ $newKeys = []; // new items
+
+ if (!$itemKeys) {
+ if (!$currentKeys) {
+ Z_Core::debug("No related items added", 4);
+ return false;
+ }
+ }
+ else {
+ foreach ($itemKeys as $itemKey) {
+ if ($itemKey == $this->key) {
+ Z_Core::debug("Can't relate item to itself in Zotero.Item.setRelatedItems()", 2);
+ continue;
+ }
+
+ if (in_array($itemKey, $currentKeys)) {
+ Z_Core::debug("Item {$this->key} is already related to item $itemKey");
+ $oldKeys[] = $itemKey;
+ continue;
+ }
+
+ // TODO: check if related on other side (like client)?
+
+ $newKeys[] = $itemKey;
+ }
+ }
+
+ // If new or changed keys, update relations with new related items
+ if ($newKeys || sizeOf($oldKeys) != sizeOf($currentKeys)) {
+ $prefix = Zotero_URI::getLibraryURI($this->libraryID) . "/items/";
+ $relations->$predicate = array_map(function ($key) use ($prefix) {
+ return $prefix . $key;
+ }, array_merge($oldKeys, $newKeys));
+ $this->setRelations($relations);
+ return true;
+ }
+ else {
+ Z_Core::debug('Related items not changed', 4);
+ return false;
+ }
+ }
+
+
+ protected function loadRelations($reload = false) {
+ if ($this->loaded['relations'] && !$reload) return;
+
+ if (!$this->id) {
+ return;
+ }
+
+ Z_Core::debug("Loading relations for item $this->id");
+
+ $this->loadPrimaryData(false, true);
+
+ $itemURI = Zotero_URI::getItemURI($this);
+
+ $relations = Zotero_Relations::getByURIs($this->libraryID, $itemURI);
+ $relations = array_map(function ($rel) {
+ return [$rel->predicate, $rel->object];
+ }, $relations);
+
+ // Related items are bidirectional, so include any with this item as the object
+ $reverseRelations = Zotero_Relations::getByURIs(
+ $this->libraryID, false, Zotero_Relations::$relatedItemPredicate, $itemURI
+ );
+ foreach ($reverseRelations as $rel) {
+ $r = [$rel->predicate, $rel->subject];
+ // Only add if not already added in other direction
+ if (!in_array($r, $relations)) {
+ $relations[] = $r;
+ }
+ }
+
+ // Also include any owl:sameAs relations with this item as the object
+ // (as sent by client via classic sync)
+ $reverseRelations = Zotero_Relations::getByURIs(
+ $this->libraryID, false, Zotero_Relations::$linkedObjectPredicate, $itemURI
+ );
+ foreach ($reverseRelations as $rel) {
+ $relations[] = [$rel->predicate, $rel->subject];
+ }
+
+ // TEMP: Get old-style related items
+ //
+ // Add related items
+ $sql = "SELECT `key` FROM itemRelated IR "
+ . "JOIN items I ON (IR.linkedItemID=I.itemID) "
+ . "WHERE IR.itemID=?";
+ $relatedItemKeys = Zotero_DB::columnQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+ if ($relatedItemKeys) {
+ $prefix = Zotero_URI::getLibraryURI($this->libraryID) . "/items/";
+ $predicate = Zotero_Relations::$relatedItemPredicate;
+ foreach ($relatedItemKeys as $key) {
+ $relations[] = [$predicate, $prefix . $key];
+ }
+ }
+ // Reverse as well
+ $sql = "SELECT `key` FROM itemRelated IR JOIN items I USING (itemID) WHERE IR.linkedItemID=?";
+ $reverseRelatedItemKeys = Zotero_DB::columnQuery(
+ $sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)
+ );
+ if ($reverseRelatedItemKeys) {
+ $prefix = Zotero_URI::getLibraryURI($this->libraryID) . "/items/";
+ $predicate = Zotero_Relations::$relatedItemPredicate;
+ foreach ($reverseRelatedItemKeys as $key) {
+ $relations[] = [$predicate, $prefix . $key];
+ }
+ }
+
+ $this->relations = $relations;
+ $this->loaded['relations'] = true;
+ $this->clearChanged('relations');
+ }
+
+
+ private function getETag() {
+ if (!$this->loaded['primaryData']) {
+ $this->loadPrimaryData();
+ }
+ return md5($this->serverDateModified . $this->version);
+ }
+
+
+ private function getCacheKey($mode, $cacheVersion=false) {
+ if (!$this->loaded['primaryData']) {
+ $this->loadPrimaryData();
+ }
+
+ if (!$this->id) {
+ return false;
+ }
+ if (!$mode) {
+ throw new Exception('$mode not provided');
+ }
+ return $mode
+ . "_". $this->id
+ . "_" . $this->version
+ . ($cacheVersion ? "_" . $cacheVersion : "");
+ }
+
+
+ /**
+ * Throw if item is a top-level attachment and isn't either a file attachment (imported or linked)
+ * or an imported web PDF
+ *
+ * NOTE: This is currently unused, because 1) these items still exist in people's databases from
+ * early Zotero versions (and could be modified and uploaded at any time) and 2) it's apparently
+ * still possible to create them on Linux/Windows by dragging child items out, which is a bug.
+ * In any case, if this were to be enforced, the client would need to properly prevent that on all
+ * platforms, convert those items in a schema update step by adding parent items (which would
+ * probably make people unhappy (though so would things breaking because we forgot they existed in
+ * old databases)), and old clients would need to be cut off from syncing.
+ */
+ private function checkTopLevelAttachment() {
+ if (!$this->isAttachment()) {
+ return;
+ }
+ if ($this->getSourceKey()) {
+ return;
+ }
+ $linkMode = $this->attachmentLinkMode;
+ if ($linkMode == 'linked_url'
+ || ($linkMode == 'imported_url' && $this->attachmentContentType != 'application/pdf')) {
+ throw new Exception("Only file attachments and PDFs can be top-level items", Z_ERROR_INVALID_INPUT);
+ }
+ }
+}
+?>
\ No newline at end of file
diff --git a/model/old_Items.inc.php b/model/old_Items.inc.php
new file mode 100644
index 000000000..ea7cf3344
--- /dev/null
+++ b/model/old_Items.inc.php
@@ -0,0 +1,2587 @@
+
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ This file is part of the Zotero Data Server.
+
+ Copyright © 2010 Center for History and New Media
+ George Mason University, Fairfax, Virginia, USA
+ http://zotero.org
+
+ This program 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.
+
+ This program 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 this program. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+class Zotero_Items {
+ use Zotero_DataObjects;
+
+ private static $objectType = 'item';
+ private static $primaryDataSQLParts = [
+ 'id' => 'O.itemID',
+ 'libraryID' => 'O.libraryID',
+ 'key' => 'O.key',
+ 'itemTypeID' => 'O.itemTypeID',
+ 'dateAdded' => 'O.dateAdded',
+ 'dateModified' => 'O.dateModified',
+ 'serverDateModified' => 'O.serverDateModified',
+ 'version' => 'O.version'
+ ];
+
+ public static $maxDataValueLength = 65535;
+ public static $maxAnnotationTextLength = 7500;
+ public static $maxAnnotationPageLabelLength = 50;
+ public static $maxAnnotationPositionLength = 65535;
+ public static $defaultAnnotationColor = '#ffd400';
+
+ /**
+ *
+ * TODO: support limit?
+ *
+ * @param {Integer[]}
+ * @param {Boolean}
+ */
+ public static function getDeleted($libraryID, $asIDs) {
+ $sql = "SELECT itemID FROM deletedItems JOIN items USING (itemID) WHERE libraryID=?";
+ $ids = Zotero_DB::columnQuery($sql, $libraryID, Zotero_Shards::getByLibraryID($libraryID));
+ if (!$ids) {
+ return array();
+ }
+ if ($asIDs) {
+ return $ids;
+ }
+ return self::get($libraryID, $ids);
+ }
+
+
+ public static function search($libraryID, $onlyTopLevel = false, array $params = [], Zotero_Permissions $permissions = null) {
+ $rnd = "_" . uniqid($libraryID . "_");
+
+ $results = array('results' => array(), 'total' => 0);
+
+ $shardID = Zotero_Shards::getByLibraryID($libraryID);
+
+ $includeTrashed = $params['includeTrashed'];
+
+ $isPublications = !empty($params['publications']);
+ if ($isPublications && Zotero_Libraries::getType($libraryID) == 'publications') {
+ $isPublications = false;
+ }
+
+ $includeNotes = true;
+ if (!$isPublications && $permissions && !$permissions->canAccess($libraryID, 'notes')) {
+ $includeNotes = false;
+ }
+
+ // Pass a list of itemIDs, for when the initial search is done via SQL
+ $itemIDs = !empty($params['itemIDs']) ? $params['itemIDs'] : array();
+ $itemKeys = $params['itemKey'];
+
+ $titleSort = !empty($params['sort']) && $params['sort'] == 'title';
+ $topLevelItemSort = !empty($params['sort'])
+ && in_array($params['sort'], ['itemType', 'dateAdded', 'dateModified', 'serverDateModified', 'addedBy']);
+
+ // For /top, don't use a parent-items table if not needed, since it prevents index use.
+ // This dramatically improves performance for the `/top?format=versions&since=` request used
+ // by the desktop client for syncing.
+ //
+ // The parent-items table is necessary when there are search parameters that match child
+ // items. This is conceptually a little muddled but is basically determined by what's needed
+ // by the web library. For example, you should be able to search by child item key in the
+ // search bar and see the parent item, so `itemKey` needs to use the parent-items table, but
+ // `since` is only used by syncing, and there's not a clear use case for returning the
+ // parent items of child items modified since a given version, so `since` can just match on
+ // the top-level items.
+ //
+ // When matching parent items directly, we can exclude child items with `ITL.itemID IS NULL`.
+ $skipITLI = $onlyTopLevel
+ // /top?itemKey=[child key]
+ && !$itemKeys
+ // /top?itemType=annotation
+ && empty($params['itemType'])
+ // /top?q=[child note title]
+ && empty($params['q'])
+ // /top?tag=[child tag]
+ && empty($params['tag']);
+
+ $sql = "SELECT SQL_CALC_FOUND_ROWS DISTINCT ";
+
+ // In /top mode, use the top-level item's values for most joins
+ if ($onlyTopLevel && !$skipITLI) {
+ $itemIDSelector = "COALESCE(ITL.topLevelItemID, I.itemID)";
+ $itemKeySelector = "COALESCE(ITLI.key, I.key)";
+ $itemVersionSelector = "COALESCE(ITLI.version, I.version)";
+ $itemTypeIDSelector = "COALESCE(ITLI.itemTypeID, I.itemTypeID)";
+ }
+ else {
+ $itemIDSelector = "I.itemID";
+ $itemKeySelector = "I.key";
+ $itemVersionSelector = "I.version";
+ $itemTypeIDSelector = "I.itemTypeID";
+ }
+
+ if ($params['format'] == 'keys' || $params['format'] == 'versions') {
+ // In /top mode, display the parent item of matching items
+ $sql .= "$itemKeySelector AS `key`";
+
+ if ($params['format'] == 'versions') {
+ $sql .= ", $itemVersionSelector AS version";
+ }
+ }
+ else {
+ $sql .= "$itemIDSelector AS itemID";
+ }
+ $sql .= " FROM items I ";
+ $sqlParams = array($libraryID);
+
+ // For /top, we need the top-level item's itemID
+ if ($onlyTopLevel) {
+ $sql .= "LEFT JOIN itemTopLevel ITL ON (ITL.itemID=I.itemID) ";
+
+ // For some /top requests, pull in the top-level item's items row
+ if (!$skipITLI
+ && ($params['format'] == 'keys' || $params['format'] == 'versions' || $topLevelItemSort)) {
+ $sql .= "LEFT JOIN items ITLI ON (ITLI.itemID=$itemIDSelector) ";
+ }
+ }
+
+ // For 'q' we need the note; for sorting by title, we need the note title
+ if (!empty($params['q']) || $titleSort) {
+ $sql .= "LEFT JOIN itemNotes INo ON (INo.itemID=I.itemID) ";
+ }
+
+ // Pull in titles
+ if (!empty($params['q']) || $titleSort) {
+ $titleFieldIDs = array_merge(
+ array(Zotero_ItemFields::getID('title')),
+ Zotero_ItemFields::getTypeFieldsFromBase('title')
+ );
+ $sql .= "LEFT JOIN itemData IDT ON (IDT.itemID=I.itemID AND IDT.fieldID IN "
+ . "(" . implode(',', $titleFieldIDs) . ")) ";
+ }
+
+ // When sorting by title in /top mode, we need the title of the parent item
+ if ($onlyTopLevel && $titleSort) {
+ $titleSortDataTable = "IDTSort";
+ $titleSortNoteTable = "INoSort";
+ $sql .= "LEFT JOIN itemData IDTSort ON (IDTSort.itemID=$itemIDSelector AND "
+ . "IDTSort.fieldID IN (" . implode(',', $titleFieldIDs) . ")) "
+ . "LEFT JOIN itemNotes INoSort ON (INoSort.itemID=$itemIDSelector) ";
+ }
+ else {
+ $titleSortDataTable = "IDT";
+ $titleSortNoteTable = "INo";
+ }
+
+ if (!empty($params['q'])) {
+ // Pull in creators
+ $sql .= "LEFT JOIN itemCreators IC ON (IC.itemID=I.itemID) "
+ . "LEFT JOIN creators C ON (C.creatorID=IC.creatorID) ";
+
+ // Pull in dates
+ $dateFieldIDs = array_merge(
+ array(Zotero_ItemFields::getID('date')),
+ Zotero_ItemFields::getTypeFieldsFromBase('date')
+ );
+ $sql .= "LEFT JOIN itemData IDD ON (IDD.itemID=I.itemID AND IDD.fieldID IN "
+ . "(" . implode(',', $dateFieldIDs) . ")) ";
+ }
+
+ if ($includeTrashed) {
+ if (!empty($params['trashedItemsOnly'])) {
+ $sql .= "JOIN deletedItems DI ON (DI.itemID=I.itemID) ";
+ }
+ }
+ else {
+ $sql .= "LEFT JOIN deletedItems DI ON (DI.itemID=I.itemID) ";
+
+ // In /top mode, we don't want to show results for deleted parents or children
+ if ($onlyTopLevel && !$skipITLI) {
+ $sql .= "LEFT JOIN deletedItems DIP ON (DIP.itemID=$itemIDSelector) ";
+ }
+ }
+
+ if ($isPublications) {
+ $sql .= "LEFT JOIN publicationsItems PI ON (PI.itemID=I.itemID) ";
+ }
+
+ if (!empty($params['sort'])) {
+ switch ($params['sort']) {
+ case 'title':
+ case 'creator':
+ $sql .= "LEFT JOIN itemSortFields ISF ON (ISF.itemID=$itemIDSelector) ";
+ break;
+
+ case 'date':
+ // When sorting by date in /top mode, we need the date of the parent item
+ if ($onlyTopLevel) {
+ $sortTable = "IDDSort";
+ // Pull in dates
+ $dateFieldIDs = array_merge(
+ array(Zotero_ItemFields::getID('date')),
+ Zotero_ItemFields::getTypeFieldsFromBase('date')
+ );
+ $sql .= "LEFT JOIN itemData IDDSort ON (IDDSort.itemID=$itemIDSelector AND "
+ . "IDDSort.fieldID IN (" . implode(',', $dateFieldIDs) . ")) ";
+ }
+ // If we didn't already pull in dates for a quick search, pull in here
+ else {
+ $sortTable = "IDD";
+ if (empty($params['q'])) {
+ $dateFieldIDs = array_merge(
+ array(Zotero_ItemFields::getID('date')),
+ Zotero_ItemFields::getTypeFieldsFromBase('date')
+ );
+ $sql .= "LEFT JOIN itemData IDD ON (IDD.itemID=I.itemID AND IDD.fieldID IN ("
+ . implode(',', $dateFieldIDs) . ")) ";
+ }
+ }
+ break;
+
+ case 'itemType':
+ $locale = 'en-US';
+ $types = Zotero_ItemTypes::getAll($locale);
+ // TEMP: get localized string
+ // DEBUG: Why is attachment skipped in getAll()?
+ $types[] = array(
+ 'id' => 14,
+ 'localized' => 'Attachment'
+ );
+ foreach ($types as $type) {
+ $sql2 = "INSERT IGNORE INTO tmpItemTypeNames VALUES (?, ?, ?)";
+ Zotero_DB::query(
+ $sql2,
+ array(
+ $type['id'],
+ $locale,
+ $type['localized']
+ ),
+ $shardID
+ );
+ }
+
+ // Join temp table to query
+ $sql .= "JOIN tmpItemTypeNames TITN ON (TITN.itemTypeID=$itemTypeIDSelector) ";
+ break;
+
+ case 'addedBy':
+ $isGroup = Zotero_Libraries::getType($libraryID) == 'group';
+ if ($isGroup) {
+ $sql2 = "SELECT DISTINCT createdByUserID FROM items
+ JOIN groupItems USING (itemID) WHERE
+ createdByUserID IS NOT NULL AND ";
+ if ($itemIDs) {
+ $sql2 .= "itemID IN ("
+ . implode(', ', array_fill(0, sizeOf($itemIDs), '?'))
+ . ") ";
+ $createdByUserIDs = Zotero_DB::columnQuery($sql2, $itemIDs, $shardID);
+ }
+ else {
+ $sql2 .= "libraryID=?";
+ $createdByUserIDs = Zotero_DB::columnQuery($sql2, $libraryID, $shardID);
+ }
+
+ // Populate temp table with usernames
+ if ($createdByUserIDs) {
+ $toAdd = array();
+ foreach ($createdByUserIDs as $createdByUserID) {
+ $toAdd[] = array(
+ $createdByUserID,
+ Zotero_Users::getName($createdByUserID)
+ );
+ }
+
+ $sql2 = "INSERT IGNORE INTO tmpCreatedByUsers VALUES ";
+ Zotero_DB::bulkInsert($sql2, $toAdd, 50, false, $shardID);
+
+ // Join temp table to query
+ $sql .= "LEFT JOIN groupItems GI ON (GI.itemID=I.itemID)
+ LEFT JOIN tmpCreatedByUsers TCBU ON (TCBU.userID=GI.createdByUserID) ";
+ }
+ }
+ break;
+ }
+ }
+
+ $sql .= "WHERE I.libraryID=? ";
+
+ if (!$includeTrashed) {
+ $sql .= "AND DI.itemID IS NULL ";
+
+ // Hide deleted parents in /top mode
+ if ($onlyTopLevel && !$skipITLI) {
+ $sql .= "AND DIP.itemID IS NULL ";
+ }
+ }
+
+ if ($isPublications) {
+ $sql .= "AND PI.itemID IS NOT NULL ";
+ }
+
+ // Search on title, creators, and dates
+ if (!empty($params['q'])) {
+ $parts = Zotero_Utilities::parseSearchString($params['q']);
+ foreach ($parts as $part) {
+ $sql .= "AND (";
+
+ $sql .= "IDT.value LIKE ? ";
+ $sqlParams[] = '%' . $part['text'] . '%';
+
+ $sql .= "OR INo.title LIKE ? ";
+ $sqlParams[] = '%' . $part['text'] . '%';
+
+ $sql .= "OR TRIM(CONCAT(firstName, ' ', lastName)) LIKE ? ";
+ $sqlParams[] = '%' . $part['text'] . '%';
+
+ $sql .= "OR SUBSTR(IDD.value, 1, 4) = ?";
+ $sqlParams[] = $part['text'];
+
+ // Full-text search
+ if ($params['qmode'] == 'everything') {
+ $ftKeys = Zotero_FullText::searchInLibrary($libraryID, $part['text']);
+ if ($ftKeys) {
+ $sql .= " OR I.key IN ("
+ . implode(', ', array_fill(0, sizeOf($ftKeys), '?'))
+ . ") ";
+ $sqlParams = array_merge($sqlParams, $ftKeys);
+ }
+ }
+
+ $sql .= ") ";
+ }
+ }
+
+ // Search on itemType
+ if (!empty($params['itemType'])) {
+ $itemTypes = Zotero_API::getSearchParamValues($params, 'itemType');
+ if ($itemTypes) {
+ if (sizeOf($itemTypes) > 1) {
+ throw new Exception("Cannot specify 'itemType' more than once", Z_ERROR_INVALID_INPUT);
+ }
+ $itemTypes = $itemTypes[0];
+
+ $itemTypeIDs = array();
+ foreach ($itemTypes['values'] as $itemType) {
+ $itemTypeID = Zotero_ItemTypes::getID($itemType);
+ if (!$itemTypeID) {
+ throw new Exception("Invalid itemType '{$itemType}'", Z_ERROR_INVALID_INPUT);
+ }
+ $itemTypeIDs[] = $itemTypeID;
+ }
+
+ $sql .= "AND I.itemTypeID " . ($itemTypes['negation'] ? "NOT " : "") . "IN ("
+ . implode(',', array_fill(0, sizeOf($itemTypeIDs), '?'))
+ . ") ";
+ $sqlParams = array_merge($sqlParams, $itemTypeIDs);
+ }
+ }
+
+ if (!$includeNotes) {
+ $sql .= "AND I.itemTypeID != 1 ";
+ }
+
+ if (!empty($params['since'])) {
+ $sql .= "AND $itemVersionSelector > ? ";
+ $sqlParams[] = $params['since'];
+ }
+
+ // TEMP: for sync transition
+ if (!empty($params['sincetime']) && $params['sincetime'] != 1) {
+ $sql .= "AND I.serverDateModified >= FROM_UNIXTIME(?) ";
+ $sqlParams[] = $params['sincetime'];
+ }
+
+ // Tags
+ //
+ // ?tag=foo
+ // ?tag=foo bar // phrase
+ // ?tag=-foo // negation
+ // ?tag=\-foo // literal hyphen (only for first character)
+ // ?tag=foo&tag=bar // AND
+ $tagSets = Zotero_API::getSearchParamValues($params, 'tag');
+
+ if ($tagSets) {
+ $sql2 = "SELECT itemID FROM items WHERE libraryID=?\n";
+ $sqlParams2 = array($libraryID);
+
+ $positives = array();
+ $negatives = array();
+
+ foreach ($tagSets as $set) {
+ $tagIDs = array();
+
+ foreach ($set['values'] as $tag) {
+ $ids = Zotero_Tags::getIDs($libraryID, $tag, true);
+ if (!$ids) {
+ $ids = array(0);
+ }
+ $tagIDs = array_merge($tagIDs, $ids);
+ }
+
+ $tagIDs = array_unique($tagIDs);
+
+ $tmpSQL = "SELECT itemID FROM items JOIN itemTags USING (itemID) "
+ . "WHERE tagID IN (" . implode(',', array_fill(0, sizeOf($tagIDs), '?')) . ")";
+ $ids = Zotero_DB::columnQuery($tmpSQL, $tagIDs, $shardID);
+
+ if (!$ids) {
+ // If no negative tags, skip this tag set
+ if ($set['negation']) {
+ continue;
+ }
+
+ // If no positive tags, return no matches
+ return $results;
+ }
+
+ $ids = $ids ? $ids : array();
+ $sql2 .= " AND itemID " . ($set['negation'] ? "NOT " : "") . " IN ("
+ . implode(',', array_fill(0, sizeOf($ids), '?')) . ")";
+ $sqlParams2 = array_merge($sqlParams2, $ids);
+ }
+
+ $tagItems = Zotero_DB::columnQuery($sql2, $sqlParams2, $shardID);
+
+ // No matches
+ if (!$tagItems) {
+ return $results;
+ }
+
+ // Combine with passed ids
+ if ($itemIDs) {
+ $itemIDs = array_intersect($itemIDs, $tagItems);
+ // None of the tag matches match the passed ids
+ if (!$itemIDs) {
+ return $results;
+ }
+ }
+ else {
+ $itemIDs = $tagItems;
+ }
+ }
+
+ if ($itemIDs) {
+ $sql .= "AND $itemIDSelector IN ("
+ . implode(', ', array_map(function ($itemID) {
+ return (int) $itemID;
+ }, $itemIDs))
+ . ") ";
+ }
+
+ if ($itemKeys) {
+ $sql .= "AND I.key IN ("
+ . implode(', ', array_fill(0, sizeOf($itemKeys), '?'))
+ . ") ";
+ $sqlParams = array_merge($sqlParams, $itemKeys);
+ }
+
+ // If we're not using a parent-items table, limit to top-level items using itemTopLevel
+ if ($skipITLI) {
+ $sql .= "AND ITL.itemID IS NULL ";
+ }
+
+ $sql .= "ORDER BY ";
+
+ if (!empty($params['sort'])) {
+ switch ($params['sort']) {
+ case 'dateAdded':
+ case 'dateModified':
+ case 'serverDateModified':
+ if ($onlyTopLevel && !$skipITLI) {
+ $orderSQL = "ITLI." . $params['sort'];
+ }
+ else {
+ $orderSQL = "I." . $params['sort'];
+ }
+ break;
+
+
+ case 'itemType';
+ $orderSQL = "TITN.itemTypeName";
+ /*
+ // Optional method for sorting by localized item type name, which would avoid
+ // the INSERT and JOIN above and allow these requests to use DB read replicas
+ $locale = 'en-US';
+ $types = Zotero_ItemTypes::getAll($locale);
+ // TEMP: get localized string
+ // DEBUG: Why is attachment skipped in getAll()?
+ $types[] = [
+ 'id' => 14,
+ 'localized' => 'Attachment'
+ ];
+ usort($types, function ($a, $b) {
+ return strcasecmp($a['localized'], $b['localized']);
+ });
+ // Pass order of localized item type names for sorting
+ // e.g., FIELD(14, 12, 14, 26...) for sorting "Attachment" after "Artwork"
+ $orderSQL = "FIELD($itemTypeIDSelector, "
+ . implode(", ", array_map(function ($x) {
+ return $x['id'];
+ }, $types)) . ")";
+ // If itemTypeID isn't found in passed list (currently only for NSF Reviewer),
+ // sort last
+ $orderSQL = "IFNULL(NULLIF($orderSQL, 0), 99999)";
+ // All items have types, so no need to check for empty sort values
+ $params['emptyFirst'] = true;
+ */
+ break;
+
+ case 'title':
+ $orderSQL = "IFNULL(COALESCE(sortTitle, $titleSortDataTable.value, $titleSortNoteTable.title), '')";
+ break;
+
+ case 'creator':
+ $orderSQL = "ISF.creatorSummary";
+ break;
+
+ // TODO: generic base field mapping-aware sorting
+ case 'date':
+ $orderSQL = "$sortTable.value";
+ break;
+
+ case 'addedBy':
+ if ($isGroup && $createdByUserIDs) {
+ $orderSQL = "TCBU.username";
+ }
+ else {
+ $orderSQL = (($onlyTopLevel && !$skipITLI) ? "ITLI" : "I") . ".dateAdded";
+ }
+ break;
+
+ case 'itemKeyList':
+ $orderSQL = "FIELD(I.key,"
+ . implode(',', array_fill(0, sizeOf($itemKeys), '?')) . ")";
+ $sqlParams = array_merge($sqlParams, $itemKeys);
+ break;
+
+ default:
+ $fieldID = Zotero_ItemFields::getID($params['sort']);
+ if (!$fieldID) {
+ throw new Exception("Invalid order field '" . $params['sort'] . "'");
+ }
+ $orderSQL = "(SELECT value FROM itemData WHERE itemID=$itemIDSelector AND fieldID=?)";
+ if (!$params['emptyFirst']) {
+ $sqlParams[] = $fieldID;
+ }
+ $sqlParams[] = $fieldID;
+ }
+
+ if (!empty($params['direction'])) {
+ $dir = $params['direction'];
+ }
+ else {
+ $dir = "ASC";
+ }
+
+ if (!$params['emptyFirst']) {
+ $sql .= "IFNULL($orderSQL, '') = '' $dir, ";
+ }
+
+ $sql .= $orderSQL . " $dir, ";
+ }
+ $sql .= "I.version " . (!empty($params['direction']) ? $params['direction'] : "ASC")
+ . ", I.itemID " . (!empty($params['direction']) ? $params['direction'] : "ASC") . " ";
+
+ if (!empty($params['limit'])) {
+ $sql .= "LIMIT ?, ?";
+ $sqlParams[] = $params['start'] ? $params['start'] : 0;
+ $sqlParams[] = $params['limit'];
+ }
+
+ // Log SQL statement with embedded parameters
+ /*if (true || !empty($_GET['sqldebug'])) {
+ error_log($onlyTopLevel);
+
+ $debugSQL = "";
+ $parts = explode("?", $sql);
+ $debugSQLParams = $sqlParams;
+ foreach ($parts as $part) {
+ $val = array_shift($debugSQLParams);
+ $debugSQL .= $part;
+ if (!is_null($val)) {
+ $debugSQL .= is_int($val) ? $val : '"' . $val . '"';
+ }
+ }
+ error_log($debugSQL . ";");
+ }*/
+
+ if ($params['format'] == 'versions') {
+ $rows = Zotero_DB::query($sql, $sqlParams, $shardID);
+ }
+ // keys and ids
+ else {
+ $rows = Zotero_DB::columnQuery($sql, $sqlParams, $shardID);
+ }
+
+ $results['total'] = Zotero_DB::valueQuery("SELECT FOUND_ROWS()", false, $shardID);
+ if ($rows) {
+ if ($params['format'] == 'keys'
+ // Used internally
+ || $params['format'] == 'ids') {
+ $results['results'] = $rows;
+ }
+ else if ($params['format'] == 'versions') {
+ foreach ($rows as $row) {
+ $results['results'][$row['key']] = $row['version'];
+ }
+ }
+ else {
+ $results['results'] = Zotero_Items::get($libraryID, $rows);
+ }
+ }
+
+ return $results;
+ }
+
+
+ /**
+ * Store item in internal id-based cache
+ */
+ public static function cache(Zotero_Item $item) {
+ if (isset(self::$objectCache[$item->id])) {
+ Z_Core::debug("Item $item->id is already cached");
+ }
+
+ self::$itemsByID[$item->id] = $item;
+ }
+
+
+ public static function updateVersions($items, $userID=false) {
+ $libraryShards = array();
+ $libraryIsGroup = array();
+ $shardItemIDs = array();
+ $shardGroupItemIDs = array();
+ $libraryItems = array();
+
+ foreach ($items as $item) {
+ $libraryID = $item->libraryID;
+ $itemID = $item->id;
+
+ // Index items by shard
+ if (isset($libraryShards[$libraryID])) {
+ $shardID = $libraryShards[$libraryID];
+ $shardItemIDs[$shardID][] = $itemID;
+ }
+ else {
+ $shardID = Zotero_Shards::getByLibraryID($libraryID);
+ $libraryShards[$libraryID] = $shardID;
+ $shardItemIDs[$shardID] = array($itemID);
+ }
+
+ // Separate out group items by shard
+ if (!isset($libraryIsGroup[$libraryID])) {
+ $libraryIsGroup[$libraryID] =
+ Zotero_Libraries::getType($libraryID) == 'group';
+ }
+ if ($libraryIsGroup[$libraryID]) {
+ if (isset($shardGroupItemIDs[$shardID])) {
+ $shardGroupItemIDs[$shardID][] = $itemID;
+ }
+ else {
+ $shardGroupItemIDs[$shardID] = array($itemID);
+ }
+ }
+
+ // Index items by library
+ if (!isset($libraryItems[$libraryID])) {
+ $libraryItems[$libraryID] = array();
+ }
+ $libraryItems[$libraryID][] = $item;
+ }
+
+ Zotero_DB::beginTransaction();
+ foreach ($shardItemIDs as $shardID => $itemIDs) {
+ // Group item data
+ if ($userID && isset($shardGroupItemIDs[$shardID])) {
+ $sql = "UPDATE groupItems SET lastModifiedByUserID=? "
+ . "WHERE itemID IN ("
+ . implode(',', array_fill(0, sizeOf($shardGroupItemIDs[$shardID]), '?')) . ")";
+ Zotero_DB::query(
+ $sql,
+ array_merge(array($userID), $shardGroupItemIDs[$shardID]),
+ $shardID
+ );
+ }
+ }
+ foreach ($libraryItems as $libraryID => $items) {
+ $itemIDs = array();
+ foreach ($items as $item) {
+ $itemIDs[] = $item->id;
+ }
+ $version = Zotero_Libraries::getUpdatedVersion($libraryID);
+ $sql = "UPDATE items SET version=? WHERE itemID IN "
+ . "(" . implode(',', array_fill(0, sizeOf($itemIDs), '?')) . ")";
+ Zotero_DB::query($sql, array_merge(array($version), $itemIDs), $shardID);
+ }
+ Zotero_DB::commit();
+
+ foreach ($libraryItems as $libraryID => $items) {
+ foreach ($items as $item) {
+ $item->reload();
+ }
+
+ $libraryKeys = array_map(function ($item) use ($libraryID) {
+ return $libraryID . "/" . $item->key;
+ }, $items);
+
+ Zotero_Notifier::trigger('modify', 'item', $libraryKeys);
+ }
+ }
+
+
+ /**
+ * Set the top-level item for a set of items
+ *
+ * @param {Integer[]} $itemIDs
+ * @param {Integer} $topLevelItemID
+ */
+ public static function setTopLevelItem($itemIDs, $topLevelItemID, $shardID) {
+ if (!$itemIDs) return;
+
+ $params = [];
+ $sql = "INSERT INTO itemTopLevel (itemID, topLevelItemID) "
+ . "VALUES " . implode(", ", array_fill(0, sizeOf($itemIDs), "(?, ?)")) . " "
+ . "ON DUPLICATE KEY UPDATE topLevelItemID=VALUES(topLevelItemID)";
+ $stmt = Zotero_DB::getStatement($sql, false, $shardID);
+ foreach ($itemIDs as $itemID) {
+ $params[] = $itemID;
+ $params[] = $topLevelItemID;
+ }
+ $stmt->execute($params);
+ }
+
+
+ public static function clearTopLevelItem($itemID, $shardID) {
+ $sql = "DELETE FROM itemTopLevel WHERE itemID=?";
+ Zotero_DB::query($sql, $itemID, $shardID);
+ }
+
+
+ /**
+ * Converts a DOMElement item to a Zotero_Item object
+ *
+ * @param DOMElement $xml Item data as DOMElement
+ * @return Zotero_Item Zotero item object
+ */
+ public static function convertXMLToItem(DOMElement $xml, $skipCreators = []) {
+ // Get item type id, adding custom type if necessary
+ $itemTypeName = $xml->getAttribute('itemType');
+ $itemTypeID = Zotero_ItemTypes::getID($itemTypeName);
+ if (!$itemTypeID) {
+ $itemTypeID = Zotero_ItemTypes::addCustomType($itemTypeName);
+ }
+
+ // Primary fields
+ $libraryID = (int) $xml->getAttribute('libraryID');
+ $itemObj = self::getByLibraryAndKey($libraryID, $xml->getAttribute('key'));
+ if (!$itemObj) {
+ $itemObj = new Zotero_Item;
+ $itemObj->libraryID = $libraryID;
+ $itemObj->key = $xml->getAttribute('key');
+ }
+ $itemObj->setField('itemTypeID', $itemTypeID, false, true);
+ $itemObj->setField('dateAdded', $xml->getAttribute('dateAdded'), false, true);
+ $itemObj->setField('dateModified', $xml->getAttribute('dateModified'), false, true);
+
+ $xmlFields = array();
+ $xmlCreators = array();
+ $xmlNote = null;
+ $xmlPath = null;
+ $xmlRelated = null;
+ $childNodes = $xml->childNodes;
+ foreach ($childNodes as $child) {
+ switch ($child->nodeName) {
+ case 'field':
+ $xmlFields[] = $child;
+ break;
+
+ case 'creator':
+ $xmlCreators[] = $child;
+ break;
+
+ case 'note':
+ $xmlNote = $child;
+ break;
+
+ case 'path':
+ $xmlPath = $child;
+ break;
+
+ case 'related':
+ $xmlRelated = $child;
+ break;
+ }
+ }
+
+ // Item data
+ $setFields = array();
+ foreach ($xmlFields as $field) {
+ // TODO: add custom fields
+
+ $fieldName = $field->getAttribute('name');
+ // Special handling for renamed computerProgram 'version' field
+ if ($itemTypeID == 32 && $fieldName == 'version') {
+ $fieldName = 'versionNumber';
+ }
+ $itemObj->setField($fieldName, $field->nodeValue, false, true);
+ $setFields[$fieldName] = true;
+ }
+ $previousFields = $itemObj->getUsedFields(true);
+
+ foreach ($previousFields as $field) {
+ if (!isset($setFields[$field])) {
+ $itemObj->setField($field, false, false, true);
+ }
+ }
+
+ $deleted = $xml->getAttribute('deleted');
+ $itemObj->deleted = ($deleted == 'true' || $deleted == '1');
+
+ // Creators
+ $i = 0;
+ foreach ($xmlCreators as $creator) {
+ // TODO: add custom creator types
+
+ $key = $creator->getAttribute('key');
+ $creatorObj = Zotero_Creators::getByLibraryAndKey($libraryID, $key);
+ // If creator doesn't exist locally (e.g., if it was deleted locally
+ // and appears in a new/modified item remotely), get it from within
+ // the item's creator block, where a copy should be provided
+ if (!$creatorObj) {
+ $subcreator = $creator->getElementsByTagName('creator')->item(0);
+ if (!$subcreator) {
+ if (!empty($skipCreators[$libraryID]) && in_array($key, $skipCreators[$libraryID])) {
+ error_log("Skipping empty referenced creator $key for item $libraryID/$itemObj->key");
+ continue;
+ }
+ throw new Exception("Data for missing local creator $key not provided", Z_ERROR_CREATOR_NOT_FOUND);
+ }
+ $creatorObj = Zotero_Creators::convertXMLToCreator($subcreator, $libraryID);
+ if ($creatorObj->key != $key) {
+ throw new Exception("Creator key " . $creatorObj->key .
+ " does not match item creator key $key");
+ }
+ }
+ if (Zotero_Utilities::unicodeTrim($creatorObj->firstName) === ''
+ && Zotero_Utilities::unicodeTrim($creatorObj->lastName) === '') {
+ continue;
+ }
+ $creatorTypeID = Zotero_CreatorTypes::getID($creator->getAttribute('creatorType'));
+ $itemObj->setCreator($i, $creatorObj, $creatorTypeID);
+ $i++;
+ }
+
+ // Remove item's remaining creators not in XML
+ $numCreators = $itemObj->numCreators();
+ $rem = $numCreators - $i;
+ for ($j=0; $j<$rem; $j++) {
+ // Keep removing last creator
+ $itemObj->removeCreator($i);
+ }
+
+ // Both notes and attachments might have parents and notes
+ if ($itemTypeName == 'note' || $itemTypeName == 'attachment') {
+ $sourceItemKey = $xml->getAttribute('sourceItem');
+ $itemObj->setSource($sourceItemKey ? $sourceItemKey : false);
+ $itemObj->setNote($xmlNote ? $xmlNote->nodeValue : "");
+ }
+
+ // Attachment metadata
+ if ($itemTypeName == 'attachment') {
+ $itemObj->attachmentLinkMode = (int) $xml->getAttribute('linkMode');
+ $itemObj->attachmentMIMEType = $xml->getAttribute('mimeType');
+ $itemObj->attachmentCharset = $xml->getAttribute('charset');
+ // Cast to string to be 32-bit safe
+ $storageModTime = (string) $xml->getAttribute('storageModTime');
+ $itemObj->attachmentStorageModTime = $storageModTime ? $storageModTime : null;
+ $storageHash = $xml->getAttribute('storageHash');
+ $itemObj->attachmentStorageHash = $storageHash ? $storageHash : null;
+ $itemObj->attachmentPath = $xmlPath ? $xmlPath->nodeValue : "";
+ }
+
+ // Related items
+ if ($xmlRelated && $xmlRelated->nodeValue) {
+ $relatedKeys = explode(' ', $xmlRelated->nodeValue);
+ }
+ else {
+ $relatedKeys = array();
+ }
+ $itemObj->relatedItems = $relatedKeys;
+
+ return $itemObj;
+ }
+
+
+ /**
+ * Converts a Zotero_Item object to a SimpleXMLElement Atom object
+ *
+ * Note: Increment Z_CONFIG::$CACHE_VERSION_ATOM_ENTRY when changing
+ * the response.
+ *
+ * @param object $item Zotero_Item object
+ * @param string $content
+ * @return SimpleXMLElement Item data as SimpleXML element
+ */
+ public static function convertItemToAtom(Zotero_Item $item, $queryParams, $permissions, $sharedData=null) {
+ $t = microtime(true);
+
+ // Uncached stuff or parts of the cache key
+ $version = $item->version;
+ $parent = $item->getSource();
+ $isRegularItem = !$parent && $item->isRegularItem();
+
+ $props = $item->getUncachedResponseProps($queryParams, $permissions);
+ $downloadDetails = $props['downloadDetails'];
+ $numChildren = $props['numChildren'];
+
+ // changes based on group visibility in v1
+ if ($queryParams['v'] < 2) {
+ $id = Zotero_URI::getItemURI($item, false, true);
+ }
+ else {
+ $id = Zotero_URI::getItemURI($item);
+ }
+ $libraryType = Zotero_Libraries::getType($item->libraryID);
+
+ // Any query parameters that have an effect on the output
+ // need to be added here
+ $allowedParams = array(
+ 'content',
+ 'style',
+ 'css',
+ 'linkwrap',
+ 'publications'
+ );
+ $cachedParams = Z_Array::filterKeys($queryParams, $allowedParams);
+
+ $cacheVersion = 4;
+ $cacheKey = "atomEntry_" . $item->libraryID . "/" . $item->id . "_"
+ . md5(
+ $version
+ . json_encode($cachedParams)
+ . ($downloadDetails ? 'hasFile' : '')
+ . ($libraryType == 'group' ? 'id' . $id : '')
+ )
+ . "_" . $queryParams['v']
+ // For code-based changes
+ . "_" . $cacheVersion
+ // For data-based changes
+ . (isset(Z_CONFIG::$CACHE_VERSION_ATOM_ENTRY)
+ ? "_" . Z_CONFIG::$CACHE_VERSION_ATOM_ENTRY
+ : "")
+ // If there's bib content, include the bib cache version
+ . ((in_array('bib', $queryParams['content'])
+ && isset(Z_CONFIG::$CACHE_VERSION_BIB))
+ ? "_" . Z_CONFIG::$CACHE_VERSION_BIB
+ : "");
+
+ $xmlstr = Z_Core::$MC->get($cacheKey);
+ if ($xmlstr) {
+ try {
+ // TEMP: Strip control characters
+ $xmlstr = Zotero_Utilities::cleanString($xmlstr, true);
+
+ $doc = new DOMDocument;
+ $doc->loadXML($xmlstr);
+ $xpath = new DOMXpath($doc);
+ $xpath->registerNamespace('atom', Zotero_Atom::$nsAtom);
+ $xpath->registerNamespace('zapi', Zotero_Atom::$nsZoteroAPI);
+ $xpath->registerNamespace('xhtml', Zotero_Atom::$nsXHTML);
+
+ // Make sure numChildren reflects the current permissions
+ if ($isRegularItem) {
+ $xpath->query('/atom:entry/zapi:numChildren')
+ ->item(0)->nodeValue = $numChildren;
+ }
+
+ // To prevent PHP from messing with namespace declarations,
+ // we have to extract, remove, and then add back
+ // subelements. Otherwise the subelements become, say,
+ // instead
+ // of just , and
+ // xmlns:default="http://www.w3.org/1999/xhtml" gets added to
+ // the parent . While you might reasonably think that
+ //
+ // echo $xml->saveXML();
+ //
+ // and
+ //
+ // $xml = new SimpleXMLElement($xml->saveXML());
+ // echo $xml->saveXML();
+ //
+ // would be identical, you would be wrong.
+ $multiFormat = !!$xpath
+ ->query('/atom:entry/atom:content/zapi:subcontent')
+ ->length;
+
+ $contentNodes = array();
+ if ($multiFormat) {
+ $contentNodes = $xpath->query('/atom:entry/atom:content/zapi:subcontent');
+ }
+ else {
+ $contentNodes = $xpath->query('/atom:entry/atom:content');
+ }
+
+ foreach ($contentNodes as $contentNode) {
+ $contentParts = array();
+ while ($contentNode->hasChildNodes()) {
+ $contentParts[] = $doc->saveXML($contentNode->firstChild);
+ $contentNode->removeChild($contentNode->firstChild);
+ }
+
+ foreach ($contentParts as $part) {
+ if (!trim($part)) {
+ continue;
+ }
+
+ // Strip the namespace and add it back via SimpleXMLElement,
+ // which keeps it from being changed later
+ if (preg_match('%^<[^>]+xmlns="http://www.w3.org/1999/xhtml"%', $part)) {
+ $part = preg_replace(
+ '%^(<[^>]+)xmlns="http://www.w3.org/1999/xhtml"%', '$1', $part
+ );
+ $html = new SimpleXMLElement($part);
+ $html['xmlns'] = "http://www.w3.org/1999/xhtml";
+ $subNode = dom_import_simplexml($html);
+ $importedNode = $doc->importNode($subNode, true);
+ $contentNode->appendChild($importedNode);
+ }
+ else if (preg_match('%^<[^>]+xmlns="http://zotero.org/ns/transfer"%', $part)) {
+ $part = preg_replace(
+ '%^(<[^>]+)xmlns="http://zotero.org/ns/transfer"%', '$1', $part
+ );
+ $html = new SimpleXMLElement($part);
+ $html['xmlns'] = "http://zotero.org/ns/transfer";
+ $subNode = dom_import_simplexml($html);
+ $importedNode = $doc->importNode($subNode, true);
+ $contentNode->appendChild($importedNode);
+ }
+ // Non-XML blocks get added back as-is
+ else {
+ $docFrag = $doc->createDocumentFragment();
+ $docFrag->appendXML($part);
+ $contentNode->appendChild($docFrag);
+ }
+ }
+ }
+
+ $xml = simplexml_import_dom($doc);
+
+ StatsD::timing("api.items.itemToAtom.cached", (microtime(true) - $t) * 1000);
+ StatsD::increment("memcached.items.itemToAtom.hit");
+
+ // Skip the cache every 10 times for now, to ensure cache sanity
+ if (Z_Core::probability(10)) {
+ $xmlstr = $xml->saveXML();
+ }
+ else {
+ return $xml;
+ }
+ }
+ catch (Exception $e) {
+ error_log($xmlstr);
+ error_log("WARNING: " . $e);
+ }
+ }
+
+ $content = $queryParams['content'];
+ $contentIsHTML = sizeOf($content) == 1 && $content[0] == 'html';
+ $contentParamString = urlencode(implode(',', $content));
+ $style = $queryParams['style'];
+
+ $entry = ''
+ . '';
+ $xml = new SimpleXMLElement($entry);
+
+ $title = $item->getDisplayTitle(true);
+ $title = $title ? $title : '[Untitled]';
+ $xml->title = $title;
+
+ $author = $xml->addChild('author');
+ $createdByUserID = null;
+ $lastModifiedByUserID = null;
+ switch (Zotero_Libraries::getType($item->libraryID)) {
+ case 'group':
+ $createdByUserID = $item->createdByUserID;
+ // Used for zapi:lastModifiedByUser below
+ $lastModifiedByUserID = $item->lastModifiedByUserID;
+ break;
+ }
+ if ($createdByUserID) {
+ try {
+ $author->name = Zotero_Users::getName($createdByUserID);
+ $author->uri = Zotero_URI::getUserURI($createdByUserID);
+ }
+ // If user no longer exists, use library for author instead
+ catch (Exception $e) {
+ if (!Zotero_Users::exists($createdByUserID)) {
+ $author->name = Zotero_Libraries::getName($item->libraryID);
+ $author->uri = Zotero_URI::getLibraryURI($item->libraryID);
+ }
+ else {
+ throw $e;
+ }
+ }
+ }
+ else {
+ $author->name = Zotero_Libraries::getName($item->libraryID);
+ $author->uri = Zotero_URI::getLibraryURI($item->libraryID);
+ }
+
+ $xml->id = $id;
+
+ $xml->published = Zotero_Date::sqlToISO8601($item->dateAdded);
+ $xml->updated = Zotero_Date::sqlToISO8601($item->dateModified);
+
+ $link = $xml->addChild("link");
+ $link['rel'] = "self";
+ $link['type'] = "application/atom+xml";
+ $href = Zotero_API::getItemURI($item) . "?format=atom";
+ if ($queryParams['publications']) {
+ $href = str_replace("/items/", "/publications/items/", $href);
+ }
+ if (!$contentIsHTML) {
+ $href .= "&content=$contentParamString";
+ }
+ $link['href'] = $href;
+
+ if ($parent) {
+ // TODO: handle group items?
+ $parentItem = Zotero_Items::get($item->libraryID, $parent);
+ $link = $xml->addChild("link");
+ $link['rel'] = "up";
+ $link['type'] = "application/atom+xml";
+ $href = Zotero_API::getItemURI($parentItem) . "?format=atom";
+ if (!$contentIsHTML) {
+ $href .= "&content=$contentParamString";
+ }
+ $link['href'] = $href;
+ }
+
+ $link = $xml->addChild('link');
+ $link['rel'] = 'alternate';
+ $link['type'] = 'text/html';
+ $link['href'] = Zotero_URI::getItemURI($item, true);
+
+ // If appropriate permissions and the file is stored in ZFS, get file request link
+ if ($downloadDetails) {
+ $details = $downloadDetails;
+ $link = $xml->addChild('link');
+ $link['rel'] = 'enclosure';
+ $type = $item->attachmentMIMEType;
+ if ($type) {
+ $link['type'] = $type;
+ }
+ $link['href'] = $details['url'];
+ if (!empty($details['filename'])) {
+ $link['title'] = $details['filename'];
+ }
+ if (isset($details['size'])) {
+ $link['length'] = $details['size'];
+ }
+ }
+
+ $xml->addChild('zapi:key', $item->key, Zotero_Atom::$nsZoteroAPI);
+ $xml->addChild('zapi:version', $item->version, Zotero_Atom::$nsZoteroAPI);
+
+ if ($lastModifiedByUserID) {
+ try {
+ $xml->addChild(
+ 'zapi:lastModifiedByUser',
+ Zotero_Users::getName($lastModifiedByUserID),
+ Zotero_Atom::$nsZoteroAPI
+ );
+ }
+ // If user no longer exists, this will fail
+ catch (Exception $e) {
+ if (Zotero_Users::exists($lastModifiedByUserID)) {
+ throw $e;
+ }
+ }
+ }
+
+ $xml->addChild(
+ 'zapi:itemType',
+ Zotero_ItemTypes::getName($item->itemTypeID),
+ Zotero_Atom::$nsZoteroAPI
+ );
+ if ($isRegularItem) {
+ $val = $item->creatorSummary;
+ if ($val !== '') {
+ $xml->addChild(
+ 'zapi:creatorSummary',
+ htmlspecialchars($val),
+ Zotero_Atom::$nsZoteroAPI
+ );
+ }
+
+ $val = $item->getField('date', true, true, true);
+ if (!is_null($val) && $val !== '') {
+ // TODO: Make sure all stored values are multipart strings
+ if (!Zotero_Date::isMultipart($val)) {
+ $val = Zotero_Date::strToMultipart($val);
+ }
+ if ($queryParams['v'] < 3) {
+ $val = substr($val, 0, 4);
+ if ($val !== '0000') {
+ $xml->addChild('zapi:year', $val, Zotero_Atom::$nsZoteroAPI);
+ }
+ }
+ else {
+ $sqlDate = Zotero_Date::multipartToSQL($val);
+ if (substr($sqlDate, 0, 4) !== '0000') {
+ $xml->addChild(
+ 'zapi:parsedDate',
+ Zotero_Date::sqlToISO8601($sqlDate),
+ Zotero_Atom::$nsZoteroAPI
+ );
+ }
+ }
+ }
+
+ $xml->addChild(
+ 'zapi:numChildren',
+ $numChildren,
+ Zotero_Atom::$nsZoteroAPI
+ );
+ }
+
+ if ($queryParams['v'] < 3) {
+ $xml->addChild(
+ 'zapi:numTags',
+ $item->numTags(),
+ Zotero_Atom::$nsZoteroAPI
+ );
+ }
+
+ $xml->content = '';
+
+ //
+ // DOM XML from here on out
+ //
+
+ $contentNode = dom_import_simplexml($xml->content);
+ $domDoc = $contentNode->ownerDocument;
+ $multiFormat = sizeOf($content) > 1;
+
+ // Create a root XML document for multi-format responses
+ if ($multiFormat) {
+ $contentNode->setAttribute('type', 'application/xml');
+ /*$multicontent = $domDoc->createElementNS(
+ Zotero_Atom::$nsZoteroAPI, 'multicontent'
+ );
+ $contentNode->appendChild($multicontent);*/
+ }
+
+ foreach ($content as $type) {
+ // Set the target to either the main
+ // or a
+ if (!$multiFormat) {
+ $target = $contentNode;
+ }
+ else {
+ $target = $domDoc->createElementNS(
+ Zotero_Atom::$nsZoteroAPI, 'subcontent'
+ );
+ $contentNode->appendChild($target);
+ }
+
+ $target->setAttributeNS(
+ Zotero_Atom::$nsZoteroAPI,
+ "zapi:type",
+ $type
+ );
+
+ if ($type == 'html') {
+ if (!$multiFormat) {
+ $target->setAttribute('type', 'xhtml');
+ }
+ $div = $domDoc->createElementNS(
+ Zotero_Atom::$nsXHTML, 'div'
+ );
+ $target->appendChild($div);
+ $html = $item->toHTML(true, $queryParams);
+ $subNode = dom_import_simplexml($html);
+ $importedNode = $domDoc->importNode($subNode, true);
+ $div->appendChild($importedNode);
+ }
+ else if ($type == 'citation') {
+ if (!$multiFormat) {
+ $target->setAttribute('type', 'xhtml');
+ }
+ if (isset($sharedData[$type][$item->libraryID . "/" . $item->key])) {
+ $html = $sharedData[$type][$item->libraryID . "/" . $item->key];
+ }
+ else {
+ if ($sharedData !== null) {
+ //error_log("Citation not found in sharedData -- retrieving individually");
+ }
+ $html = Zotero_Cite::getCitationFromCiteServer($item, $queryParams);
+ }
+ $html = new SimpleXMLElement($html);
+ $html['xmlns'] = Zotero_Atom::$nsXHTML;
+ $subNode = dom_import_simplexml($html);
+ $importedNode = $domDoc->importNode($subNode, true);
+ $target->appendChild($importedNode);
+ }
+ else if ($type == 'bib') {
+ if (!$multiFormat) {
+ $target->setAttribute('type', 'xhtml');
+ }
+ if (isset($sharedData[$type][$item->libraryID . "/" . $item->key])) {
+ $html = $sharedData[$type][$item->libraryID . "/" . $item->key];
+ }
+ else {
+ if ($sharedData !== null) {
+ //error_log("Bibliography not found in sharedData -- retrieving individually");
+ }
+ $html = Zotero_Cite::getBibliographyFromCitationServer(array($item), $queryParams);
+ }
+ $html = new SimpleXMLElement($html);
+ $html['xmlns'] = Zotero_Atom::$nsXHTML;
+ $subNode = dom_import_simplexml($html);
+ $importedNode = $domDoc->importNode($subNode, true);
+ $target->appendChild($importedNode);
+ }
+ else if ($type == 'json') {
+ if ($queryParams['v'] < 2) {
+ $target->setAttributeNS(
+ Zotero_Atom::$nsZoteroAPI,
+ "zapi:etag",
+ $item->etag
+ );
+ }
+ $textNode = $domDoc->createTextNode($item->toJSON(false, $queryParams, true));
+ $target->appendChild($textNode);
+ }
+ else if ($type == 'csljson') {
+ $arr = $item->toCSLItem();
+ $json = Zotero_Utilities::formatJSON($arr);
+ $textNode = $domDoc->createTextNode($json);
+ $target->appendChild($textNode);
+ }
+ else if (in_array($type, Zotero_Translate::$exportFormats)) {
+ $exportParams = $queryParams;
+ $exportParams['format'] = $type;
+ $export = Zotero_Translate::doExport([$item], $exportParams);
+ $target->setAttribute('type', $export['mimeType']);
+ // Insert XML into document
+ if (preg_match('/\+xml$/', $export['mimeType'])) {
+ // Strip prolog
+ $body = preg_replace('/^<\?xml.+\n/', "", $export['body']);
+ $subNode = $domDoc->createDocumentFragment();
+ $subNode->appendXML($body);
+ $target->appendChild($subNode);
+ }
+ else {
+ $textNode = $domDoc->createTextNode($export['body']);
+ $target->appendChild($textNode);
+ }
+ }
+ }
+
+ // TEMP
+ if ($xmlstr) {
+ $uncached = $xml->saveXML();
+ if ($xmlstr != $uncached) {
+ $uncached = str_replace(
+ '',
+ '',
+ $uncached
+ );
+ $uncached = str_replace(
+ '',
+ '',
+ $uncached
+ );
+ $uncached = str_replace(
+ '',
+ '',
+ $uncached
+ );
+ $uncached = str_replace(
+ '',
+ '',
+ $uncached
+ );
+ $uncached = str_replace(
+ '',
+ '',
+ $uncached
+ );
+ $uncached = str_replace(
+ '',
+ '',
+ $uncached
+ );
+ $uncached = str_replace(
+ ' | ',
+ ' | ',
+ $uncached
+ );
+
+ if ($xmlstr != $uncached) {
+ error_log("Cached Atom item entry does not match");
+ error_log(" Cached: " . $xmlstr);
+ error_log("Uncached: " . $uncached);
+
+ Z_Core::$MC->set($cacheKey, $uncached, 3600); // 1 hour for now
+ }
+ }
+ }
+ else {
+ $xmlstr = $xml->saveXML();
+ Z_Core::$MC->set($cacheKey, $xmlstr, 3600); // 1 hour for now
+ StatsD::timing("api.items.itemToAtom.uncached", (microtime(true) - $t) * 1000);
+ StatsD::increment("memcached.items.itemToAtom.miss");
+ }
+
+ return $xml;
+ }
+
+
+ /**
+ * Import an item by URL using the translation server
+ *
+ * Initial request:
+ *
+ * {
+ * "url": "https://example.com"
+ * }
+ *
+ * Response:
+ *
+ * {
+ * "url": "https://example.com",
+ * "token": "abcdefgh123456789",
+ * "items": {
+ * "0": {
+ * "title": "Item 1 Title"
+ * },
+ * "1": {
+ * "title": "Item 2 Title"
+ * },
+ * "2": {
+ * "title": "Item 3 Title"
+ * }
+ * }
+ * }
+ *
+ * Item selection for multi-item results:
+ *
+ * {
+ * "url": "https://example.com",
+ * "token": "abcdefgh123456789"
+ * "items": {
+ * "0": "Item 1 Title",
+ * "3": "Item 2 Title"
+ * }
+ * }
+ *
+ * Returns an array of keys of added items (like updateMultipleFromJSON) or an object
+ * with 'token' and 'items' properties for multi-item results
+ */
+ public static function addFromURL($json, $requestParams, $libraryID, $userID, Zotero_Permissions $permissions) {
+ self::validateJSONURL($json, $requestParams);
+
+ // Replace numeric keys with URLs for selected items
+ if (isset($json->items)) {
+ if ($requestParams['v'] >= 3 && empty($json->token)) {
+ throw new Exception("Token not provided with selected items", Z_ERROR_INVALID_INPUT);
+ }
+ $cacheKey = 'addFromURLKeyMappings_' . md5($json->url . $json->token);
+ $keyMappings = Z_Core::$MC->get($cacheKey);
+ $newItems = [];
+ foreach ($json->items as $number => $title) {
+ if (!isset($keyMappings[$number])) {
+ throw new Exception("Index '$number' not found for URL and token", Z_ERROR_INVALID_INPUT);
+ }
+ $url = $keyMappings[$number];
+ $newItems[$url] = $title;
+ }
+ $json->items = $newItems;
+ }
+ else if (isset($json->token)) {
+ throw new Exception("'token' is valid only for item selection requests", Z_ERROR_INVALID_INPUT);
+ }
+
+ $response = Zotero_Translate::doWeb(
+ $json->url,
+ isset($json->token) ? $json->token : null,
+ isset($json->items) ? $json->items : null
+ );
+
+ if (!$response || is_int($response)) {
+ return $response;
+ }
+
+ if (isset($response->items)) {
+ $items = $response->items;
+
+ // APIv3
+ if ($requestParams['v'] >= 3) {
+ for ($i = 0, $len = sizeOf($items); $i < $len; $i++) {
+ // Assign key here so that we can add notes if necessary
+ do {
+ $itemKey = Zotero_ID::getKey();
+ }
+ while (Zotero_Items::existsByLibraryAndKey($libraryID, $itemKey));
+ $items[$i]->key = $itemKey;
+ // TEMP: translation-server shouldn't include these, but as long as it does,
+ // remove them
+ unset($items[$i]->itemKey);
+ unset($items[$i]->itemVersion);
+
+ // Pull out notes and stick in separate items
+ if (isset($items[$i]->notes)) {
+ foreach ($items[$i]->notes as $note) {
+ $newNote = (object) [
+ "itemType" => "note",
+ "note" => $note->note,
+ "parentItem" => $itemKey
+ ];
+ $items[] = $newNote;
+ }
+ unset($items[$i]->notes);
+ }
+
+ // TODO: link attachments, or not possible from translation-server?
+ }
+ }
+ // APIv2
+ else {
+ for ($i = 0, $len = sizeOf($items); $i < $len; $i++) {
+ // Assign key here so that we can add notes if necessary
+ do {
+ $itemKey = Zotero_ID::getKey();
+ }
+ while (Zotero_Items::existsByLibraryAndKey($libraryID, $itemKey));
+ $items[$i]->itemKey = $itemKey;
+
+ // Pull out notes and stick in separate items
+ if (isset($items[$i]->notes)) {
+ foreach ($items[$i]->notes as $note) {
+ $newNote = (object) [
+ "itemType" => "note",
+ "note" => $note->note,
+ "parentItem" => $itemKey
+ ];
+ $items[] = $newNote;
+ }
+ unset($items[$i]->notes);
+ }
+
+ // TODO: link attachments, or not possible from translation-server?
+ }
+ }
+
+ $response = $items;
+
+ try {
+ self::validateMultiObjectJSON($response, $requestParams);
+ }
+ catch (Exception $e) {
+ error_log($e);
+ error_log(json_encode($response));
+ throw new Exception("Invalid JSON from doWeb()");
+ }
+ }
+ // Multi-item select
+ else if (isset($response->select)) {
+ $result = new stdClass;
+ $result->token = $response->token;
+
+ // Replace URLs with numeric keys for found items
+ $keyMappings = [];
+ $newItems = new stdClass;
+ $number = 0;
+ foreach ($response->select as $url => $title) {
+ $keyMappings[$number] = $url;
+ $newItems->$number = $title;
+ $number++;
+ }
+ $cacheKey = 'addFromURLKeyMappings_' . md5($json->url . $response->token);
+ Z_Core::$MC->set($cacheKey, $keyMappings, 600);
+
+ $result->select = $newItems;
+ return $result;
+ }
+ else {
+ throw new Exception("Invalid return value from doWeb()");
+ }
+
+ return self::updateMultipleFromJSON(
+ $response,
+ $requestParams,
+ $libraryID,
+ $userID,
+ $permissions,
+ false,
+ null
+ );
+ }
+
+
+ public static function updateFromJSON(Zotero_Item $item,
+ $json,
+ Zotero_Item $parentItem=null,
+ $requestParams,
+ $userID,
+ $requireVersion=0,
+ $partialUpdate=false) {
+ $json = Zotero_API::extractEditableJSON($json);
+ $exists = Zotero_API::processJSONObjectKey($item, $json, $requestParams);
+ $apiVersion = $requestParams['v'];
+
+ // computerProgram used 'version' instead of 'versionNumber' before v3
+ if ($apiVersion < 3 && isset($json->version)) {
+ $json->versionNumber = $json->version;
+ unset($json->version);
+ }
+
+ Zotero_API::checkJSONObjectVersion($item, $json, $requestParams, $requireVersion);
+ self::validateJSONItem(
+ $json,
+ $item->libraryID,
+ $exists ? $item : null,
+ $parentItem || ($exists ? !!$item->getSourceKey() : false),
+ $requestParams,
+ $partialUpdate && $exists
+ );
+
+ $changed = false;
+ $twoStage = false;
+
+ if (!Zotero_DB::transactionInProgress()) {
+ Zotero_DB::beginTransaction();
+ $transactionStarted = true;
+ }
+ else {
+ $transactionStarted = false;
+ }
+
+ // Set itemType first
+ if (isset($json->itemType)) {
+ $item->setField("itemTypeID", Zotero_ItemTypes::getID($json->itemType));
+ }
+
+ $dateModifiedProvided = false;
+ // APIv2 and below
+ $changedDateModified = false;
+ // Limit new Date Modified handling to Zotero for now. It can be applied to all v3 clients
+ // once people have time to update their code.
+ $tmpZoteroClientDateModifiedHack = !empty($_SERVER['HTTP_USER_AGENT'])
+ && (strpos($_SERVER['HTTP_USER_AGENT'], 'Firefox') !== false
+ || strpos($_SERVER['HTTP_USER_AGENT'], 'Zotero') !== false);
+
+ foreach ($json as $key=>$val) {
+ switch ($key) {
+ case 'key':
+ case 'version':
+ case 'itemKey':
+ case 'itemVersion':
+ case 'itemType':
+ case 'deleted':
+ case 'inPublications':
+ continue 2;
+
+ case 'parentItem':
+ $item->setSourceKey($val);
+ break;
+
+ case 'creators':
+ if (!$val && !$item->numCreators()) {
+ continue 2;
+ }
+
+ $orderIndex = -1;
+ foreach ($val as $newCreatorData) {
+ // JSON uses 'name' and 'firstName'/'lastName',
+ // so switch to just 'firstName'/'lastName'
+ if (isset($newCreatorData->name)) {
+ $newCreatorData->firstName = '';
+ $newCreatorData->lastName = $newCreatorData->name;
+ unset($newCreatorData->name);
+ $newCreatorData->fieldMode = 1;
+ }
+ else {
+ $newCreatorData->fieldMode = 0;
+ }
+
+ // Skip empty creators
+ if (Zotero_Utilities::unicodeTrim($newCreatorData->firstName) === ""
+ && Zotero_Utilities::unicodeTrim($newCreatorData->lastName) === "") {
+ break;
+ }
+
+ $orderIndex++;
+
+ $newCreatorTypeID = Zotero_CreatorTypes::getID($newCreatorData->creatorType);
+
+ // Same creator in this position
+ $existingCreator = $item->getCreator($orderIndex);
+ if ($existingCreator && $existingCreator['ref']->equals($newCreatorData)) {
+ // Just change the creatorTypeID
+ if ($existingCreator['creatorTypeID'] != $newCreatorTypeID) {
+ $item->setCreator($orderIndex, $existingCreator['ref'], $newCreatorTypeID);
+ }
+ continue;
+ }
+
+ // Same creator in a different position, so use that
+ $existingCreators = $item->getCreators();
+ for ($i=0,$len=sizeOf($existingCreators); $i<$len; $i++) {
+ if ($existingCreators[$i]['ref']->equals($newCreatorData)) {
+ $item->setCreator($orderIndex, $existingCreators[$i]['ref'], $newCreatorTypeID);
+ continue;
+ }
+ }
+
+ // Make a fake creator to use for the data lookup
+ $newCreator = new Zotero_Creator;
+ $newCreator->libraryID = $item->libraryID;
+ foreach ($newCreatorData as $key=>$val) {
+ if ($key == 'creatorType') {
+ continue;
+ }
+ $newCreator->$key = $val;
+ }
+
+ // Look for an equivalent creator in this library
+ $candidates = Zotero_Creators::getCreatorsWithData($item->libraryID, $newCreator, true);
+ if ($candidates) {
+ $c = Zotero_Creators::get($item->libraryID, $candidates[0]);
+ $item->setCreator($orderIndex, $c, $newCreatorTypeID);
+ continue;
+ }
+
+ // None found, so make a new one
+ $creatorID = $newCreator->save();
+ $newCreator = Zotero_Creators::get($item->libraryID, $creatorID);
+ $item->setCreator($orderIndex, $newCreator, $newCreatorTypeID);
+ }
+
+ // Remove all existing creators above the current index
+ if ($exists && $indexes = array_keys($item->getCreators())) {
+ $i = max($indexes);
+ while ($i>$orderIndex) {
+ $item->removeCreator($i);
+ $i--;
+ }
+ }
+
+ break;
+
+ case 'tags':
+ $item->setTags($val);
+ break;
+
+ case 'collections':
+ $item->setCollections($val);
+ break;
+
+ case 'relations':
+ $item->setRelations($val);
+ break;
+
+ case 'attachments':
+ case 'notes':
+ if (!$val) {
+ continue 2;
+ }
+ $twoStage = true;
+ break;
+
+ case 'note':
+ $item->setNote($val);
+ break;
+
+ //
+ // Attachment properties
+ //
+ case 'linkMode':
+ $item->attachmentLinkMode = Zotero_Attachments::linkModeNameToNumber($val, true);
+ break;
+
+ case 'contentType':
+ case 'charset':
+ case 'filename':
+ case 'path':
+ $k = "attachment" . ucwords($key);
+ // Until classic sync is removed, store paths in Mozilla relative descriptor style,
+ // and then batch convert and remove this
+ if ($key == 'path') {
+ $val = Zotero_Attachments::encodeRelativeDescriptorString($val);
+ }
+ $item->$k = $val;
+ break;
+
+ case 'md5':
+ if (!$val) {
+ continue 2;
+ }
+ $item->attachmentStorageHash = $val;
+ break;
+
+ case 'mtime':
+ if (!$val) {
+ continue 2;
+ }
+ $item->attachmentStorageModTime = $val;
+ break;
+
+ //
+ // Annotation properties
+ //
+ case 'annotationType':
+ case 'annotationAuthorName':
+ case 'annotationText':
+ case 'annotationComment':
+ case 'annotationColor':
+ case 'annotationPageLabel':
+ case 'annotationSortIndex':
+ case 'annotationPosition':
+ $item->$key = $val;
+ break;
+
+ case 'dateModified':
+ if ($apiVersion >= 3 && $tmpZoteroClientDateModifiedHack) {
+ $item->setField($key, $val);
+ $dateModifiedProvided = true;
+ }
+ else {
+ $changedDateModified = $item->setField($key, $val);
+ }
+ break;
+
+ default:
+ $item->setField($key, $val);
+ break;
+ }
+ }
+
+ if ($parentItem) {
+ $item->setSource($parentItem->id);
+ }
+ // Clear parent if not a partial update and a parentItem isn't provided
+ else if ($apiVersion >= 2 && !$partialUpdate
+ && $item->getSourceKey() && !isset($json->parentItem)) {
+ $item->setSourceKey(false);
+ }
+
+ if (isset($json->deleted) || !$partialUpdate) {
+ $item->deleted = !empty($json->deleted);
+ }
+
+ if (isset($json->inPublications) || !$partialUpdate) {
+ $item->inPublications = !empty($json->inPublications);
+ }
+
+ // Skip "Date Modified" update if only certain fields were updated (e.g., collections)
+ $skipDateModifiedUpdate = $dateModifiedProvided || !sizeOf(array_diff(
+ $item->getChanged(),
+ ['collections', 'deleted', 'inPublications', 'relations', 'tags']
+ ));
+
+ if ($item->hasChanged() && !$skipDateModifiedUpdate
+ && (($apiVersion >= 3 && $tmpZoteroClientDateModifiedHack) || !$changedDateModified)) {
+ // Update item with the current timestamp
+ $item->dateModified = Zotero_DB::getTransactionTimestamp();
+ }
+
+ $changed = $item->save($userID) || $changed;
+
+ // Additional steps that have to be performed on a saved object
+ if ($twoStage) {
+ foreach ($json as $key=>$val) {
+ switch ($key) {
+ case 'attachments':
+ if (!$val) {
+ continue 2;
+ }
+ foreach ($val as $attachmentJSON) {
+ $childItem = new Zotero_Item;
+ $childItem->libraryID = $item->libraryID;
+ self::updateFromJSON(
+ $childItem,
+ $attachmentJSON,
+ $item,
+ $requestParams,
+ $userID
+ );
+ }
+ break;
+
+ case 'notes':
+ if (!$val) {
+ continue 2;
+ }
+ $noteItemTypeID = Zotero_ItemTypes::getID("note");
+
+ foreach ($val as $note) {
+ $childItem = new Zotero_Item;
+ $childItem->libraryID = $item->libraryID;
+ $childItem->itemTypeID = $noteItemTypeID;
+ $childItem->setSource($item->id);
+ $childItem->setNote($note->note);
+ $childItem->save();
+ }
+ break;
+ }
+ }
+ }
+
+ if ($transactionStarted) {
+ Zotero_DB::commit();
+ }
+
+ return $changed;
+ }
+
+
+ /**
+ * Check for problems in the provided JSON
+ *
+ * Most checks should be performed in the data layer, either when setting property values or
+ * at save time, but these checks are helpful for 1) bailing quickly on obvious problems and
+ * 2) checking for problems that can't easily be detected in the data layer but that might
+ * indicate the API client is doing something wrong (e.g., an empty property that shouldn't be
+ * present, even if it would be ignored when saving).
+ *
+ * The catch here is that updates can be partial with POST/PATCH, so checks that depend on
+ * other values have to check values on both the JSON and, if it's an update, the existing item.
+ */
+ private static function validateJSONItem($json, $libraryID, Zotero_Item $item=null, $isChild, $requestParams, $partialUpdate=false) {
+ $isNew = !$item || !$item->version;
+
+ if (!is_object($json)) {
+ throw new Exception("Invalid item object (found " . gettype($json) . " '" . $json . "')", Z_ERROR_INVALID_INPUT);
+ }
+
+ if (isset($json->items) && is_array($json->items)) {
+ throw new Exception("An 'items' array is not valid for single-item updates", Z_ERROR_INVALID_INPUT);
+ }
+
+ $apiVersion = $requestParams['v'];
+ $libraryType = Zotero_Libraries::getType($libraryID);
+
+ // Check if child item is being converted to top-level or vice-versa, and update $isChild to the
+ // target state so that, e.g., we properly check for the required property 'collections' below
+ // when converting a child item to a top-level item
+ if ($isChild) {
+ // PATCH
+ if (($partialUpdate && isset($json->parentItem) && $json->parentItem === false)
+ // PUT
+ || (!$partialUpdate && (!isset($json->parentItem) || $json->parentItem === false))) {
+ $isChild = false;
+ }
+ // Implicit parentItem: false for PATCH if collections provided
+ //
+ // This shouldn't really happen, but there's apparently a client bug where attachments
+ // going through PDF metadata retrieval are initially being uploaded as children of
+ // unrelated items and then getting uploaded again as standalone attachments in the same
+ // collection without setting `parentItem: false`. Since child items can't be in
+ // collections themselves, we can take a `collections` property as an implicit
+ // `parentItem: false`.
+ else if ($partialUpdate && !isset($json->parentItem) && !empty($json->collections)) {
+ error_log("WARNING: 'collections' property provided without 'parentItem: false' for child item $libraryID/$json->key");
+ $json->parentItem = false;
+ $isChild = false;
+ }
+ }
+ else {
+ if (isset($json->parentItem) && $json->parentItem !== false) {
+ $isChild = true;
+ }
+ }
+
+ if ($partialUpdate) {
+ $requiredProps = [];
+ }
+ else if (isset($json->itemType) && $json->itemType == "attachment") {
+ $requiredProps = ['linkMode'];
+ }
+ else if ($isNew) {
+ $requiredProps = array('itemType');
+ }
+ else if ($apiVersion < 2) {
+ $requiredProps = array('itemType', 'tags');
+ }
+ else {
+ $requiredProps = array('itemType', 'tags', 'relations');
+ if (!$isChild) {
+ $requiredProps[] = 'collections';
+ }
+ }
+
+ foreach ($requiredProps as $prop) {
+ if (!isset($json->$prop)) {
+ throw new Exception("'$prop' property not provided", Z_ERROR_INVALID_INPUT);
+ }
+ }
+
+ // For partial updates where item type isn't provided, use the existing item type
+ if (!isset($json->itemType) && $partialUpdate) {
+ $itemType = Zotero_ItemTypes::getName($item->itemTypeID);
+ }
+ else {
+ $itemType = $json->itemType;
+ }
+
+ foreach ($json as $key=>$val) {
+ switch ($key) {
+ // Handled by Zotero_API::checkJSONObjectVersion()
+ case 'key':
+ case 'version':
+ if ($apiVersion < 3) {
+ throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+ case 'itemKey':
+ case 'itemVersion':
+ if ($apiVersion != 2) {
+ throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+
+ case 'parentItem':
+ if ($apiVersion < 2) {
+ throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT);
+ }
+ if ($val !== false) {
+ if (!Zotero_ID::isValidKey($val)) {
+ throw new Exception("'$key' must be a valid item key or false", Z_ERROR_INVALID_INPUT);
+ }
+ // Make sure 'key' != 'parentItem'
+ if (isset($json->key) && $val == $json->key) {
+ // Keep in sync with Zotero_Errors::parseException
+ throw new Exception(
+ "Item $libraryID/$val cannot be a child of itself",
+ Z_ERROR_ITEM_PARENT_SET_TO_SELF
+ );
+ }
+ }
+ break;
+
+ case 'itemType':
+ if (!is_string($val)) {
+ throw new Exception("'itemType' must be a string", Z_ERROR_INVALID_INPUT);
+ }
+
+ // TODO: Don't allow changing item type
+
+ if (!Zotero_ItemTypes::getID($val)) {
+ throw new Exception("'$val' is not a valid itemType", Z_ERROR_INVALID_INPUT);
+ }
+
+ // Parent/child checks by item type
+ if ($isChild || !empty($json->parentItem)) {
+ switch ($val) {
+ case 'note':
+ case 'attachment':
+ case 'annotation':
+ break;
+
+ default:
+ throw new Exception("Child item must be note, attachment, or annotation", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ break;
+
+ case 'tags':
+ if (!is_array($val)) {
+ throw new Exception("'$key' property must be an array", Z_ERROR_INVALID_INPUT);
+ }
+
+ foreach ($val as $tag) {
+ $empty = true;
+
+ if (!is_object($tag)) {
+ throw new Exception("Tag must be an object", Z_ERROR_INVALID_INPUT);
+ }
+
+ foreach ($tag as $k=>$v) {
+ switch ($k) {
+ case 'tag':
+ if (!is_scalar($v)) {
+ throw new Exception("Invalid tag name", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+
+ case 'type':
+ if (!is_numeric($v)) {
+ throw new Exception("Invalid tag type '$v'", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+
+ default:
+ throw new Exception("Invalid tag property '$k'", Z_ERROR_INVALID_INPUT);
+ }
+
+ $empty = false;
+ }
+
+ if ($empty) {
+ throw new Exception("Tag object is empty", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ break;
+
+ case 'collections':
+ if (!is_array($val)) {
+ throw new Exception("'$key' property must be an array", Z_ERROR_INVALID_INPUT);
+ }
+ if ($isChild && $val) {
+ throw new Exception("Child items cannot be assigned to collections", Z_ERROR_INVALID_INPUT);
+ }
+ foreach ($val as $k) {
+ if (!Zotero_ID::isValidKey($k)) {
+ throw new Exception("'$k' is not a valid collection key", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ break;
+
+ case 'relations':
+ if ($apiVersion < 2) {
+ throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT);
+ }
+
+ if (!is_object($val)
+ // Allow an empty array, because it's annoying for some clients otherwise
+ && !(is_array($val) && empty($val))) {
+ throw new Exception("'$key' property must be an object", Z_ERROR_INVALID_INPUT);
+ }
+ foreach ($val as $predicate => $object) {
+ if (!in_array($predicate, Zotero_Relations::$allowedItemPredicates)) {
+ throw new Exception("Unsupported predicate '$predicate'", Z_ERROR_INVALID_INPUT);
+ }
+
+ // Certain predicates allow values other than Zotero URIs
+ if (in_array($predicate, Zotero_Relations::$externalPredicates)) {
+ continue;
+ }
+
+ $arr = is_string($object) ? [$object] : $object;
+ foreach ($arr as $uri) {
+ if (!preg_match('/^http:\/\/zotero.org\/(users|groups)\/[0-9]+\/(publications\/)?items\/[A-Z0-9]{8}$/', $uri)) {
+ throw new Exception("'$key' values currently must be Zotero item URIs", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ }
+ break;
+
+ case 'creators':
+ if (!is_array($val)) {
+ throw new Exception("'$key' property must be an array", Z_ERROR_INVALID_INPUT);
+ }
+
+ foreach ($val as $creator) {
+ $empty = true;
+
+ if (!isset($creator->creatorType)) {
+ throw new Exception("creator object must contain 'creatorType'", Z_ERROR_INVALID_INPUT);
+ }
+
+ if ((!isset($creator->name) || trim($creator->name) == "")
+ && (!isset($creator->firstName) || trim($creator->firstName) == "")
+ && (!isset($creator->lastName) || trim($creator->lastName) == "")) {
+ // On item creation, ignore single nameless creator,
+ // because that's in the item template that the API returns
+ if (sizeOf($val) == 1 && $isNew) {
+ continue;
+ }
+ else {
+ throw new Exception("creator object must contain 'firstName'/'lastName' or 'name'", Z_ERROR_INVALID_INPUT);
+ }
+ }
+
+ foreach ($creator as $k=>$v) {
+ switch ($k) {
+ case 'creatorType':
+ $creatorTypeID = Zotero_CreatorTypes::getID($v);
+ if (!$creatorTypeID) {
+ throw new Exception("'$v' is not a valid creator type", Z_ERROR_INVALID_INPUT);
+ }
+ $itemTypeID = Zotero_ItemTypes::getID($itemType);
+ if (!Zotero_CreatorTypes::isValidForItemType($creatorTypeID, $itemTypeID)) {
+ // Allow 'author' in all item types, but reject other invalid creator types
+ if ($creatorTypeID != Zotero_CreatorTypes::getID('author')) {
+ throw new Exception("'$v' is not a valid creator type for item type '$itemType'", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ break;
+
+ case 'firstName':
+ if (!isset($creator->lastName)) {
+ throw new Exception("'lastName' creator field must be set if 'firstName' is set", Z_ERROR_INVALID_INPUT);
+ }
+ if (isset($creator->name)) {
+ throw new Exception("'firstName' and 'name' creator fields are mutually exclusive", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+
+ case 'lastName':
+ if (!isset($creator->firstName)) {
+ throw new Exception("'firstName' creator field must be set if 'lastName' is set", Z_ERROR_INVALID_INPUT);
+ }
+ if (isset($creator->name)) {
+ throw new Exception("'lastName' and 'name' creator fields are mutually exclusive", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+
+ case 'name':
+ if (isset($creator->firstName)) {
+ throw new Exception("'firstName' and 'name' creator fields are mutually exclusive", Z_ERROR_INVALID_INPUT);
+ }
+ if (isset($creator->lastName)) {
+ throw new Exception("'lastName' and 'name' creator fields are mutually exclusive", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+
+ default:
+ throw new Exception("Invalid creator property '$k'", Z_ERROR_INVALID_INPUT);
+ }
+
+ $empty = false;
+ }
+
+ if ($empty) {
+ throw new Exception("Creator object is empty", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ break;
+
+ case 'note':
+ switch ($itemType) {
+ case 'note':
+ case 'attachment':
+ break;
+
+ default:
+ throw new Exception("'note' property is valid only for note and attachment items", Z_ERROR_INVALID_INPUT);
+ }
+
+ if ($itemType == 'attachment') {
+ $linkMode = isset($json->linkMode)
+ ? strtolower($json->linkMode)
+ : $item->attachmentLinkMode;
+ if ($linkMode == 'embedded_image' && $val !== '') {
+ throw new Exception("'note' property is not valid for embedded images", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ break;
+
+ case 'attachments':
+ case 'notes':
+ if ($apiVersion > 1) {
+ throw new Exception("'$key' property is no longer supported", Z_ERROR_INVALID_INPUT);
+ }
+
+ if (!$isNew) {
+ throw new Exception("'$key' property is valid only for new items", Z_ERROR_INVALID_INPUT);
+ }
+
+ if (!is_array($val)) {
+ throw new Exception("'$key' property must be an array", Z_ERROR_INVALID_INPUT);
+ }
+
+ foreach ($val as $child) {
+ // Check child item type ('attachment' or 'note')
+ $t = substr($key, 0, -1);
+ if (isset($child->itemType) && $child->itemType != $t) {
+ throw new Exception("Child $t must be of itemType '$t'", Z_ERROR_INVALID_INPUT);
+ }
+ if ($key == 'note') {
+ if (!isset($child->note)) {
+ throw new Exception("'note' property not provided for child note", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ }
+ break;
+
+ case 'deleted':
+ // Accept a boolean or 0/1, but lie about it
+ if (gettype($val) != 'boolean' && $val !== 0 && $val !== 1) {
+ throw new Exception("'deleted' must be a boolean");
+ }
+ break;
+
+ case 'inPublications':
+ if (!$val) {
+ break;
+ }
+
+ if ($libraryType != 'user') {
+ throw new Exception(
+ ucwords($libraryType) . " items cannot be added to My Publications",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+
+ if (!$isChild && ($itemType == 'note' || $itemType == 'attachment')) {
+ throw new Exception(
+ "Top-level notes and attachments cannot be added to My Publications",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+
+ if ($itemType == 'attachment') {
+ $linkMode = isset($json->linkMode)
+ ? strtolower($json->linkMode)
+ : $item->attachmentLinkMode;
+ if ($linkMode == 'linked_file') {
+ throw new Exception(
+ "Linked-file attachments cannot be added to My Publications",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ }
+ break;
+
+ // Attachment properties
+ case 'linkMode':
+ try {
+ $linkMode = Zotero_Attachments::linkModeNumberToName(
+ Zotero_Attachments::linkModeNameToNumber($val, true)
+ );
+ }
+ catch (Exception $e) {
+ throw new Exception("'$val' is not a valid linkMode", Z_ERROR_INVALID_INPUT);
+ }
+ // Don't allow changing of linkMode
+ if (!$isNew && $linkMode != $item->attachmentLinkMode) {
+ throw new Exception("Cannot change attachment linkMode", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+
+ case 'contentType':
+ case 'charset':
+ case 'filename':
+ case 'md5':
+ case 'mtime':
+ case 'path':
+ if ($itemType != 'attachment') {
+ throw new Exception("'$key' is valid only for attachment items", Z_ERROR_INVALID_INPUT);
+ }
+
+ $linkMode = isset($json->linkMode)
+ ? strtolower($json->linkMode)
+ : $item->attachmentLinkMode;
+
+ switch ($key) {
+ case 'filename':
+ case 'md5':
+ case 'mtime':
+ if (strpos($linkMode, 'imported_') !== 0 && $linkMode != 'embedded_image') {
+ throw new Exception("'$key' is valid only for imported and embedded-image attachments", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+
+ case 'path':
+ if ($linkMode != 'linked_file') {
+ throw new Exception("'$key' is valid only for linked file attachment items", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+ }
+
+ switch ($key) {
+ case 'contentType':
+ case 'charset':
+ case 'filename':
+ case 'path':
+ $propName = 'attachment' . ucwords($key);
+ break;
+
+ case 'md5':
+ $propName = 'attachmentStorageHash';
+ break;
+
+ case 'mtime':
+ $propName = 'attachmentStorageModTime';
+ break;
+ }
+
+ if ($linkMode == 'embedded_image') {
+ switch ($key) {
+ case 'charset':
+ if ($val !== '') {
+ throw new Exception("'$key' is not valid for embedded images", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+ }
+ }
+
+ if ($key == 'mtime' || $key == 'md5') {
+ if ($item && $item->$propName !== $val && is_null($val)) {
+ //throw new Exception("Cannot change existing '$key' to null", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ if ($key == 'md5') {
+ if ($val && !preg_match("/^[a-f0-9]{32}$/", $val)) {
+ throw new Exception("'$val' is not a valid MD5 hash", Z_ERROR_INVALID_INPUT);
+ }
+ }
+ break;
+
+ // Annotation properties
+ case 'annotationType':
+ case 'annotationAuthorName':
+ case 'annotationText':
+ case 'annotationComment':
+ case 'annotationColor':
+ case 'annotationPageLabel':
+ case 'annotationSortIndex':
+ case 'annotationPosition':
+ if ($itemType != 'annotation') {
+ throw new Exception("'$key' is valid only for annotation items", Z_ERROR_INVALID_INPUT);
+ }
+ if ($key == 'annotationText'
+ && ($isNew ? $json->annotationType != 'highlight' : $item->annotationType != 'highlight')) {
+ throw new Exception(
+ "'$key' can only be set for highlight annotations",
+ Z_ERROR_INVALID_INPUT
+ );
+ }
+ break;
+
+ case 'accessDate':
+ if ($apiVersion >= 3
+ && $val !== ''
+ && $val != 'CURRENT_TIMESTAMP'
+ && !Zotero_Date::isSQLDate($val)
+ && !Zotero_Date::isSQLDateTime($val)
+ && !Zotero_Date::isISO8601($val)) {
+ throw new Exception("'$key' must be in ISO 8601 or UTC 'YYYY-MM-DD[ hh:mm:ss]' format or 'CURRENT_TIMESTAMP' ($val)", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+
+ case 'dateAdded':
+ case 'dateModified':
+ if (!Zotero_Date::isSQLDateTime($val) && !Zotero_Date::isISO8601($val)) {
+ throw new Exception("'$key' must be in ISO 8601 or UTC 'YYYY-MM-DD hh:mm:ss' format ($val)", Z_ERROR_INVALID_INPUT);
+ }
+ break;
+
+ default:
+ if (!Zotero_ItemFields::getID($key)) {
+ throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT);
+ }
+ if (is_array($val)) {
+ throw new Exception("Unexpected array for property '$key'", Z_ERROR_INVALID_INPUT);
+ }
+
+ break;
+ }
+ }
+ }
+
+
+ private static function validateJSONURL($json) {
+ if (!is_object($json)) {
+ throw new Exception("Unexpected " . gettype($json) . " '" . $json . "'", Z_ERROR_INVALID_INPUT);
+ }
+
+ if (!isset($json->url)) {
+ throw new Exception("URL not provided");
+ }
+
+ if (!is_string($json->url)) {
+ throw new Exception("'url' must be a string", Z_ERROR_INVALID_INPUT);
+ }
+
+ if (isset($json->items) && !is_object($json->items)) {
+ throw new Exception("'items' must be an object", Z_ERROR_INVALID_INPUT);
+ }
+
+ if (isset($json->token) && !is_string($json->token)) {
+ throw new Exception("Invalid token", Z_ERROR_INVALID_INPUT);
+ }
+
+ foreach ($json as $key => $val) {
+ if (!in_array($key, array('url', 'token', 'items'))) {
+ throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT);
+ }
+
+ if ($key == 'items' && sizeOf(get_object_vars($val)) > Zotero_API::$maxTranslateItems) {
+ throw new Exception("Cannot translate more than " . Zotero_API::$maxTranslateItems . " items at a time", Z_ERROR_UPLOAD_TOO_LARGE);
+ }
+ }
+ }
+
+
+ private static function loadItems($libraryID, $itemIDs=array()) {
+ $shardID = Zotero_Shards::getByLibraryID($libraryID);
+
+ $sql = self::getPrimaryDataSQL() . "1";
+
+ // TODO: optimize
+ if ($itemIDs) {
+ foreach ($itemIDs as $itemID) {
+ if (!is_int($itemID)) {
+ throw new Exception("Invalid itemID $itemID");
+ }
+ }
+ $sql .= ' AND itemID IN ('
+ . implode(',', array_fill(0, sizeOf($itemIDs), '?'))
+ . ')';
+ }
+
+ $stmt = Zotero_DB::getStatement($sql, "loadItems_" . sizeOf($itemIDs), $shardID);
+ $itemRows = Zotero_DB::queryFromStatement($stmt, $itemIDs);
+ $loadedItemIDs = array();
+
+ if ($itemRows) {
+ foreach ($itemRows as $row) {
+ if ($row['libraryID'] != $libraryID) {
+ throw new Exception("Item $itemID isn't in library $libraryID", Z_ERROR_OBJECT_LIBRARY_MISMATCH);
+ }
+
+ $itemID = $row['id'];
+ $loadedItemIDs[] = $itemID;
+
+ // Item isn't loaded -- create new object and stuff in array
+ if (!isset(self::$objectCache[$itemID])) {
+ $item = new Zotero_Item;
+ $item->loadFromRow($row, true);
+ self::$objectCache[$itemID] = $item;
+ }
+ // Existing item -- reload in place
+ else {
+ self::$objectCache[$itemID]->loadFromRow($row, true);
+ }
+ }
+ }
+
+ if (!$itemIDs) {
+ // If loading all items, remove old items that no longer exist
+ $ids = array_keys(self::$objectCache);
+ foreach ($ids as $id) {
+ if (!in_array($id, $loadedItemIDs)) {
+ throw new Exception("Unimplemented");
+ //$this->unload($id);
+ }
+ }
+ }
+ }
+
+
+ public static function getSortTitle($title) {
+ if (!$title) {
+ return '';
+ }
+ return mb_strcut(preg_replace('/^[[({\-"\'“‘ ]+(.*)[\])}\-"\'”’ ]*?$/Uu', '$1', $title), 0, Zotero_Notes::$MAX_TITLE_LENGTH);
+ }
+}
+
+Zotero_Items::init();
\ No newline at end of file
diff --git a/model/old_LIbraries.inc.php b/model/old_LIbraries.inc.php
new file mode 100644
index 000000000..2b68e4d2f
--- /dev/null
+++ b/model/old_LIbraries.inc.php
@@ -0,0 +1,466 @@
+
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ This file is part of the Zotero Data Server.
+
+ Copyright © 2010 Center for History and New Media
+ George Mason University, Fairfax, Virginia, USA
+ http://zotero.org
+
+ This program 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.
+
+ This program 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 this program. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+class Zotero_Libraries {
+ private static $libraryTypeCache = array();
+ private static $libraryJSONCache = [];
+ private static $originalVersions = array();
+ private static $updatedVersions = array();
+
+ public static function add($type, $shardID) {
+ if (!$shardID) {
+ throw new Exception('$shardID not provided');
+ }
+
+ Zotero_DB::beginTransaction();
+
+ $sql = "INSERT INTO libraries (libraryType, shardID) VALUES (?,?)";
+ $libraryID = Zotero_DB::query($sql, array($type, $shardID));
+
+ $sql = "INSERT INTO shardLibraries (libraryID, libraryType) VALUES (?,?)";
+ Zotero_DB::query($sql, array($libraryID, $type), $shardID);
+
+ Zotero_DB::commit();
+
+ return $libraryID;
+ }
+
+
+ public static function exists($libraryID) {
+ $sql = "SELECT COUNT(*) FROM libraries WHERE libraryID=?";
+ return !!Zotero_DB::valueQuery($sql, $libraryID);
+ }
+
+
+ public static function getName($libraryID) {
+ $type = self::getType($libraryID);
+ switch ($type) {
+ case 'user':
+ $userID = Zotero_Users::getUserIDFromLibraryID($libraryID);
+ return Zotero_Users::getName($userID);
+
+ case 'publications':
+ $userID = Zotero_Users::getUserIDFromLibraryID($libraryID);
+ return Zotero_Users::getName($userID) . "’s Publications";
+
+ case 'group':
+ $groupID = Zotero_Groups::getGroupIDFromLibraryID($libraryID);
+ $group = Zotero_Groups::get($groupID);
+ return $group->name;
+
+ default:
+ throw new Exception("Invalid library type '$libraryType'");
+ }
+ }
+
+
+ /**
+ * Get the type-specific id (userID or groupID) of the library
+ */
+ public static function getLibraryTypeID($libraryID) {
+ $type = self::getType($libraryID);
+ switch ($type) {
+ case 'user':
+ return Zotero_Users::getUserIDFromLibraryID($libraryID);
+
+ case 'publications':
+ throw new Exception("Cannot get library type id of publications library");
+
+ case 'group':
+ return Zotero_Groups::getGroupIDFromLibraryID($libraryID);
+
+ default:
+ throw new Exception("Invalid library type '$libraryType'");
+ }
+ }
+
+
+ public static function getType($libraryID) {
+ if (!$libraryID) {
+ throw new Exception("Library not provided");
+ }
+
+ if (isset(self::$libraryTypeCache[$libraryID])) {
+ return self::$libraryTypeCache[$libraryID];
+ }
+
+ $cacheKey = 'libraryType_' . $libraryID;
+ $libraryType = Z_Core::$MC->get($cacheKey);
+ if ($libraryType) {
+ self::$libraryTypeCache[$libraryID] = $libraryType;
+ return $libraryType;
+ }
+ $sql = "SELECT libraryType FROM libraries WHERE libraryID=?";
+ $libraryType = Zotero_DB::valueQuery($sql, $libraryID);
+ if (!$libraryType) {
+ throw new Exception("Library $libraryID does not exist");
+ }
+
+ self::$libraryTypeCache[$libraryID] = $libraryType;
+ Z_Core::$MC->set($cacheKey, $libraryType);
+
+ return $libraryType;
+ }
+
+
+ public static function getOwner($libraryID) {
+ return Zotero_Users::getUserIDFromLibraryID($libraryID);
+ }
+
+
+ public static function getUserLibraries($userID) {
+ return array_merge(
+ array(Zotero_Users::getLibraryIDFromUserID($userID)),
+ Zotero_Groups::getUserGroupLibraries($userID)
+ );
+ }
+
+
+ public static function getTimestamp($libraryID) {
+ $sql = "SELECT lastUpdated FROM shardLibraries WHERE libraryID=?";
+ return Zotero_DB::valueQuery(
+ $sql, $libraryID, Zotero_Shards::getByLibraryID($libraryID)
+ );
+ }
+
+
+ public static function setTimestampLock($libraryIDs, $timestamp) {
+ $fail = false;
+
+ for ($i=0, $len=sizeOf($libraryIDs); $i<$len; $i++) {
+ $libraryID = $libraryIDs[$i];
+ if (!Z_Core::$MC->add("libraryTimestampLock_" . $libraryID . "_" . $timestamp, 1, 60)) {
+ $fail = true;
+ break;
+ }
+ }
+
+ if ($fail) {
+ if ($i > 0) {
+ for ($j=$i-1; $j>=0; $j--) {
+ $libraryID = $libraryIDs[$i];
+ Z_Core::$MC->delete("libraryTimestampLock_" . $libraryID . "_" . $timestamp);
+ }
+ }
+ return false;
+ }
+
+ return true;
+ }
+
+
+ /**
+ * Get library version from the database
+ */
+ public static function getVersion($libraryID) {
+ // Default empty library
+ if ($libraryID === 0) return 0;
+
+ $sql = "SELECT version FROM shardLibraries WHERE libraryID=?";
+ $version = Zotero_DB::valueQuery(
+ $sql, $libraryID, Zotero_Shards::getByLibraryID($libraryID)
+ );
+
+ // Store original version for use by getOriginalVersion()
+ if (!isset(self::$originalVersions[$libraryID])) {
+ self::$originalVersions[$libraryID] = $version;
+ }
+ return $version;
+ }
+
+
+ /**
+ * Get the first library version retrieved during this request, or the
+ * database version if none
+ *
+ * Since the library version is updated at the start of a request,
+ * but write operations may cache data before making changes, the
+ * original, pre-update version has to be used in cache keys.
+ * Otherwise a subsequent request for the new library version might
+ * omit data that was written with that version. (The new data can't
+ * just be written with the same version because a cache write
+ * could fail.)
+ */
+ public static function getOriginalVersion($libraryID) {
+ if (isset(self::$originalVersions[$libraryID])) {
+ return self::$originalVersions[$libraryID];
+ }
+ $version = self::getVersion($libraryID);
+ self::$originalVersions[$libraryID] = $version;
+ return $version;
+ }
+
+
+ /**
+ * Get the latest library version set during this request, or the original
+ * version if none
+ */
+ public static function getUpdatedVersion($libraryID) {
+ if (isset(self::$updatedVersions[$libraryID])) {
+ return self::$updatedVersions[$libraryID];
+ }
+ return self::getOriginalVersion($libraryID);
+ }
+
+
+ public static function updateVersionAndTimestamp($libraryID) {
+ if (!is_numeric($libraryID)) {
+ throw new Exception("Invalid library ID");
+ }
+
+ $shardID = Zotero_Shards::getByLibraryID($libraryID);
+
+ $originalVersion = self::getOriginalVersion($libraryID);
+ $sql = "UPDATE shardLibraries SET version=LAST_INSERT_ID(version+1), lastUpdated=NOW() "
+ . "WHERE libraryID=?";
+ Zotero_DB::query($sql, $libraryID, $shardID);
+ $version = Zotero_DB::valueQuery("SELECT LAST_INSERT_ID()", false, $shardID);
+ // Store new version for use by getUpdatedVersion()
+ self::$updatedVersions[$libraryID] = $version;
+
+ $sql = "SELECT UNIX_TIMESTAMP(lastUpdated) FROM shardLibraries WHERE libraryID=?";
+ $timestamp = Zotero_DB::valueQuery($sql, $libraryID, $shardID);
+
+ // If library has never been written to before, mark it as having data
+ if (!$originalVersion || $originalVersion == 1) {
+ $sql = "UPDATE libraries SET hasData=1 WHERE libraryID=?";
+ Zotero_DB::query($sql, $libraryID);
+ }
+
+ Zotero_DB::registerTransactionTimestamp($timestamp);
+ }
+
+
+ public static function isLocked($libraryID) {
+ // TODO
+ throw new Exception("Use last modified timestamp?");
+ }
+
+
+ public static function userCanEdit($libraryID, $userID, $obj=null) {
+ $libraryType = Zotero_Libraries::getType($libraryID);
+ switch ($libraryType) {
+ case 'user':
+ case 'publications':
+ return $userID == Zotero_Users::getUserIDFromLibraryID($libraryID);
+
+ case 'group':
+ $groupID = Zotero_Groups::getGroupIDFromLibraryID($libraryID);
+ $group = Zotero_Groups::get($groupID);
+ if (!$group->hasUser($userID) || !$group->userCanEdit($userID)) {
+ return false;
+ }
+
+ if ($obj && $obj instanceof Zotero_Item
+ && $obj->isStoredFileAttachment()
+ && !$group->userCanEditFiles($userID)) {
+ return false;
+ }
+ return true;
+
+ default:
+ throw new Exception("Unsupported library type '$libraryType'");
+ }
+ }
+
+
+ public static function getLastStorageSync($libraryID) {
+ $sql = "SELECT UNIX_TIMESTAMP(serverDateModified) AS time FROM items
+ JOIN storageFileItems USING (itemID) WHERE libraryID=?
+ ORDER BY time DESC LIMIT 1";
+ return Zotero_DB::valueQuery(
+ $sql, $libraryID, Zotero_Shards::getByLibraryID($libraryID)
+ );
+ }
+
+
+ public static function toJSON($libraryID) {
+ if (isset(self::$libraryJSONCache[$libraryID])) {
+ return self::$libraryJSONCache[$libraryID];
+ }
+
+ $cacheVersion = 1;
+ $cacheKey = "libraryJSON_" . md5($libraryID . '_' . $cacheVersion);
+ $cached = Z_Core::$MC->get($cacheKey);
+ if ($cached) {
+ self::$libraryJSONCache[$libraryID] = $cached;
+ return $cached;
+ }
+
+ $libraryType = Zotero_Libraries::getType($libraryID);
+ if ($libraryType == 'user') {
+ $objectUserID = Zotero_Users::getUserIDFromLibraryID($libraryID);
+ $json = [
+ 'type' => $libraryType,
+ 'id' => $objectUserID,
+ 'name' => self::getName($libraryID),
+ 'links' => [
+ 'alternate' => [
+ 'href' => Zotero_URI::getUserURI($objectUserID, true),
+ 'type' => 'text/html'
+ ]
+ ]
+ ];
+ }
+ else if ($libraryType == 'publications') {
+ $objectUserID = Zotero_Users::getUserIDFromLibraryID($libraryID);
+ $json = [
+ 'type' => $libraryType,
+ 'id' => $objectUserID,
+ 'name' => self::getName($libraryID),
+ 'links' => [
+ 'alternate' => [
+ 'href' => Zotero_URI::getUserURI($objectUserID, true) . "/publications",
+ 'type' => 'text/html'
+ ]
+ ]
+ ];
+ }
+ else if ($libraryType == 'group') {
+ $objectGroupID = Zotero_Groups::getGroupIDFromLibraryID($libraryID);
+ $group = Zotero_Groups::get($objectGroupID);
+ $json = [
+ 'type' => $libraryType,
+ 'id' => $objectGroupID,
+ 'name' => self::getName($libraryID),
+ 'links' => [
+ 'alternate' => [
+ 'href' => Zotero_URI::getGroupURI($group, true),
+ 'type' => 'text/html'
+ ]
+ ]
+ ];
+ }
+ else {
+ throw new Exception("Invalid library type '$libraryType'");
+ }
+
+ self::$libraryJSONCache[$libraryID] = $json;
+ Z_Core::$MC->set($cacheKey, $json, 60);
+
+ return $json;
+ }
+
+
+ public static function clearAllData($libraryID) {
+ if (empty($libraryID)) {
+ throw new Exception("libraryID not provided");
+ }
+
+ Zotero_DB::beginTransaction();
+
+ $tables = array(
+ 'collections', 'creators', 'items', 'relations', 'savedSearches', 'tags',
+ 'syncDeleteLogIDs', 'syncDeleteLogKeys', 'settings'
+ );
+
+ $shardID = Zotero_Shards::getByLibraryID($libraryID);
+
+ self::deleteCachedData($libraryID);
+
+ // Because of the foreign key constraint on the itemID, delete MySQL full-text rows
+ // first, and then clear from Elasticsearch below
+ Zotero_FullText::deleteByLibraryMySQL($libraryID);
+
+ foreach ($tables as $table) {
+ // For items, delete annotations first, then notes and attachments, then items after
+ if ($table == 'items') {
+ $itemTypeIDs = Zotero_DB::columnQuery(
+ "SELECT itemTypeID FROM itemTypes "
+ . "WHERE itemTypeName IN ('note', 'attachment', 'annotation') "
+ . "ORDER BY itemTypeName = 'annotation' DESC"
+ );
+ $sql = "DELETE FROM $table "
+ . "WHERE libraryID=? AND itemTypeID IN (" . implode(",", $itemTypeIDs) . ") "
+ . "ORDER BY itemTypeID = {$itemTypeIDs[0]} DESC";
+ Zotero_DB::query($sql, $libraryID, $shardID);
+ }
+
+ try {
+ $sql = "DELETE FROM $table WHERE libraryID=?";
+ Zotero_DB::query($sql, $libraryID, $shardID);
+ }
+ catch (Exception $e) {
+ // ON DELETE CASCADE will only go 15 levels deep, so if we get an FK error, try
+ // deleting subcollections first, starting with the most recent, which isn't foolproof
+ // but will probably almost always do the trick.
+ if ($table == 'collections'
+ // Newer MySQL
+ && (strpos($e->getMessage(), "Foreign key cascade delete/update exceeds max depth")
+ // Older MySQL
+ || strpos($e->getMessage(), "Cannot delete or update a parent row") !== false)) {
+ $sql = "DELETE FROM collections WHERE libraryID=? "
+ . "ORDER BY parentCollectionID IS NULL, collectionID DESC";
+ Zotero_DB::query($sql, $libraryID, $shardID);
+ }
+ else {
+ throw $e;
+ }
+ }
+ }
+
+ Zotero_FullText::deleteByLibrary($libraryID);
+
+ self::updateVersionAndTimestamp($libraryID);
+
+ Zotero_Notifier::trigger("clear", "library", $libraryID);
+
+ Zotero_DB::commit();
+ }
+
+
+
+ /**
+ * Delete data from memcached
+ */
+ public static function deleteCachedData($libraryID) {
+ $shardID = Zotero_Shards::getByLibraryID($libraryID);
+
+ // Clear itemID-specific memcache values
+ $sql = "SELECT itemID FROM items WHERE libraryID=?";
+ $itemIDs = Zotero_DB::columnQuery($sql, $libraryID, $shardID);
+ if ($itemIDs) {
+ $cacheKeys = array(
+ "itemCreators",
+ "itemIsDeleted",
+ "itemRelated",
+ "itemUsedFieldIDs",
+ "itemUsedFieldNames"
+ );
+ foreach ($itemIDs as $itemID) {
+ foreach ($cacheKeys as $key) {
+ Z_Core::$MC->delete($key . '_' . $itemID);
+ }
+ }
+ }
+
+ /*foreach (Zotero_DataObjects::$objectTypes as $type=>$arr) {
+ $className = "Zotero_" . $arr['plural'];
+ call_user_func(array($className, "clearPrimaryDataCache"), $libraryID);
+ }*/
+ }
+}
+?>
\ No newline at end of file
diff --git a/model/old_Tag.inc.php b/model/old_Tag.inc.php
new file mode 100644
index 000000000..2f4aac08f
--- /dev/null
+++ b/model/old_Tag.inc.php
@@ -0,0 +1,744 @@
+
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ This file is part of the Zotero Data Server.
+
+ Copyright © 2010 Center for History and New Media
+ George Mason University, Fairfax, Virginia, USA
+ http://zotero.org
+
+ This program 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.
+
+ This program 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 this program. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+class Zotero_Tag {
+ private $id;
+ private $libraryID;
+ private $key;
+ private $name;
+ private $type;
+ private $dateAdded;
+ private $dateModified;
+ private $version;
+
+ private $loaded;
+ private $changed;
+ private $previousData;
+
+ private $linkedItemsLoaded = false;
+ private $linkedItems = array();
+
+ public function __construct() {
+ $numArgs = func_num_args();
+ if ($numArgs) {
+ throw new Exception("Constructor doesn't take any parameters");
+ }
+
+ $this->init();
+ }
+
+
+ private function init() {
+ $this->loaded = false;
+
+ $this->previousData = array();
+ $this->linkedItemsLoaded = false;
+
+ $this->changed = array();
+ $props = array(
+ 'name',
+ 'type',
+ 'dateAdded',
+ 'dateModified',
+ 'linkedItems'
+ );
+ foreach ($props as $prop) {
+ $this->changed[$prop] = false;
+ }
+ }
+
+
+ public function __get($field) {
+ if (($this->id || $this->key) && !$this->loaded) {
+ $this->load(true);
+ }
+
+ if (!property_exists('Zotero_Tag', $field)) {
+ throw new Exception("Zotero_Tag property '$field' doesn't exist");
+ }
+
+ return $this->$field;
+ }
+
+
+ public function __set($field, $value) {
+ switch ($field) {
+ case 'id':
+ case 'libraryID':
+ case 'key':
+ if ($this->loaded) {
+ throw new Exception("Cannot set $field after tag is already loaded");
+ }
+ $this->checkValue($field, $value);
+ $this->$field = $value;
+ return;
+ }
+
+ if ($this->id || $this->key) {
+ if (!$this->loaded) {
+ $this->load(true);
+ }
+ }
+ else {
+ $this->loaded = true;
+ }
+
+ $this->checkValue($field, $value);
+
+ if ($this->$field != $value) {
+ $this->prepFieldChange($field);
+ $this->$field = $value;
+ }
+ }
+
+
+ /**
+ * Check if tag exists in the database
+ *
+ * @return bool TRUE if the item exists, FALSE if not
+ */
+ public function exists() {
+ if (!$this->id) {
+ trigger_error('$this->id not set');
+ }
+
+ $sql = "SELECT COUNT(*) FROM tags WHERE tagID=?";
+ return !!Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
+ }
+
+
+ public function addItem($key) {
+ $current = $this->getLinkedItems(true);
+ if (in_array($key, $current)) {
+ Z_Core::debug("Item $key already has tag {$this->libraryID}/{$this->key}");
+ return false;
+ }
+
+ $this->prepFieldChange('linkedItems');
+ $this->linkedItems[] = $key;
+ return true;
+ }
+
+
+ public function removeItem($key) {
+ $current = $this->getLinkedItems(true);
+ $index = array_search($key, $current);
+
+ if ($index === false) {
+ Z_Core::debug("Item {$this->libraryID}/$key doesn't have tag {$this->key}");
+ return false;
+ }
+
+ $this->prepFieldChange('linkedItems');
+ array_splice($this->linkedItems, $index, 1);
+ return true;
+ }
+
+
+ public function hasChanged() {
+ // Exclude 'dateModified' from test
+ $changed = $this->changed;
+ if (!empty($changed['dateModified'])) {
+ unset($changed['dateModified']);
+ }
+ return in_array(true, array_values($changed));
+ }
+
+
+ public function save($userID=false, $full=false) {
+ if (!$this->libraryID) {
+ trigger_error("Library ID must be set before saving", E_USER_ERROR);
+ }
+
+ Zotero_Tags::editCheck($this, $userID);
+
+ if (!$this->hasChanged()) {
+ Z_Core::debug("Tag $this->id has not changed");
+ return false;
+ }
+
+ $shardID = Zotero_Shards::getByLibraryID($this->libraryID);
+
+ Zotero_DB::beginTransaction();
+
+ try {
+ $tagID = $this->id ? $this->id : Zotero_ID::get('tags');
+ $isNew = !$this->id;
+
+ Z_Core::debug("Saving tag $tagID");
+
+ $key = $this->key ? $this->key : Zotero_ID::getKey();
+ $timestamp = Zotero_DB::getTransactionTimestamp();
+ $dateAdded = $this->dateAdded ? $this->dateAdded : $timestamp;
+ $dateModified = $this->dateModified ? $this->dateModified : $timestamp;
+ $version = ($this->changed['name'] || $this->changed['type'])
+ ? Zotero_Libraries::getUpdatedVersion($this->libraryID)
+ : $this->version;
+
+ $fields = "name=?, `type`=?, dateAdded=?, dateModified=?,
+ libraryID=?, `key`=?, serverDateModified=?, version=?";
+ $params = array(
+ $this->name,
+ $this->type ? $this->type : 0,
+ $dateAdded,
+ $dateModified,
+ $this->libraryID,
+ $key,
+ $timestamp,
+ $version
+ );
+
+ try {
+ if ($isNew) {
+ $sql = "INSERT INTO tags SET tagID=?, $fields";
+ $stmt = Zotero_DB::getStatement($sql, true, $shardID);
+ Zotero_DB::queryFromStatement($stmt, array_merge(array($tagID), $params));
+
+ // Remove from delete log if it's there
+ $sql = "DELETE FROM syncDeleteLogKeys WHERE libraryID=?
+ AND objectType='tag' AND `key`=?";
+ Zotero_DB::query(
+ $sql, array($this->libraryID, $key), $shardID
+ );
+ $sql = "DELETE FROM syncDeleteLogKeys WHERE libraryID=?
+ AND objectType='tagName' AND `key`=?";
+ Zotero_DB::query(
+ $sql, array($this->libraryID, $this->name), $shardID
+ );
+ }
+ else {
+ $sql = "UPDATE tags SET $fields WHERE tagID=?";
+ $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
+ Zotero_DB::queryFromStatement($stmt, array_merge($params, array($tagID)));
+ }
+ }
+ catch (Exception $e) {
+ // If an incoming tag is the same as an existing tag, but with a different key,
+ // then delete the old tag and add its linked items to the new tag
+ if (preg_match("/Duplicate entry .+ for key 'uniqueTags'/", $e->getMessage())) {
+ // GET existing tag
+ $existing = Zotero_Tags::getIDs($this->libraryID, $this->name);
+ if (!$existing) {
+ throw new Exception("Existing tag not found");
+ }
+ foreach ($existing as $id) {
+ $tag = Zotero_Tags::get($this->libraryID, $id, true);
+ if ($tag->__get('type') == $this->type) {
+ $linked = $tag->getLinkedItems(true);
+ Zotero_Tags::delete($this->libraryID, $tag->key);
+ break;
+ }
+ }
+
+ // Save again
+ if ($isNew) {
+ $sql = "INSERT INTO tags SET tagID=?, $fields";
+ $stmt = Zotero_DB::getStatement($sql, true, $shardID);
+ Zotero_DB::queryFromStatement($stmt, array_merge(array($tagID), $params));
+
+ // Remove from delete log if it's there
+ $sql = "DELETE FROM syncDeleteLogKeys WHERE libraryID=?
+ AND objectType='tag' AND `key`=?";
+ Zotero_DB::query(
+ $sql, array($this->libraryID, $key), $shardID
+ );
+ $sql = "DELETE FROM syncDeleteLogKeys WHERE libraryID=?
+ AND objectType='tagName' AND `key`=?";
+ Zotero_DB::query(
+ $sql, array($this->libraryID, $this->name), $shardID
+ );
+
+ }
+ else {
+ $sql = "UPDATE tags SET $fields WHERE tagID=?";
+ $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
+ Zotero_DB::queryFromStatement($stmt, array_merge($params, array($tagID)));
+ }
+
+ $new = array_unique(array_merge($linked, $this->getLinkedItems(true)));
+ $this->setLinkedItems($new);
+ }
+ else {
+ throw $e;
+ }
+ }
+
+ // Linked items
+ if ($full || $this->changed['linkedItems']) {
+ $removeKeys = array();
+ $currentKeys = $this->getLinkedItems(true);
+
+ if ($full) {
+ $sql = "SELECT `key` FROM itemTags JOIN items "
+ . "USING (itemID) WHERE tagID=?";
+ $stmt = Zotero_DB::getStatement($sql, true, $shardID);
+ $dbKeys = Zotero_DB::columnQueryFromStatement($stmt, $tagID);
+ if ($dbKeys) {
+ $removeKeys = array_diff($dbKeys, $currentKeys);
+ $newKeys = array_diff($currentKeys, $dbKeys);
+ }
+ else {
+ $newKeys = $currentKeys;
+ }
+ }
+ else {
+ if (!empty($this->previousData['linkedItems'])) {
+ $removeKeys = array_diff(
+ $this->previousData['linkedItems'], $currentKeys
+ );
+ $newKeys = array_diff(
+ $currentKeys, $this->previousData['linkedItems']
+ );
+ }
+ else {
+ $newKeys = $currentKeys;
+ }
+ }
+
+ if ($removeKeys) {
+ $sql = "DELETE itemTags FROM itemTags JOIN items USING (itemID) "
+ . "WHERE tagID=? AND items.key IN ("
+ . implode(', ', array_fill(0, sizeOf($removeKeys), '?'))
+ . ")";
+ Zotero_DB::query(
+ $sql,
+ array_merge(array($this->id), $removeKeys),
+ $shardID
+ );
+ }
+
+ if ($newKeys) {
+ $sql = "INSERT INTO itemTags (tagID, itemID) "
+ . "SELECT ?, itemID FROM items "
+ . "WHERE libraryID=? AND `key` IN ("
+ . implode(', ', array_fill(0, sizeOf($newKeys), '?'))
+ . ")";
+ Zotero_DB::query(
+ $sql,
+ array_merge(array($tagID, $this->libraryID), $newKeys),
+ $shardID
+ );
+ }
+
+ //Zotero.Notifier.trigger('add', 'collection-item', $this->id . '-' . $itemID);
+ }
+
+ Zotero_DB::commit();
+
+ Zotero_Tags::cachePrimaryData(
+ array(
+ 'id' => $tagID,
+ 'libraryID' => $this->libraryID,
+ 'key' => $key,
+ 'name' => $this->name,
+ 'type' => $this->type ? $this->type : 0,
+ 'dateAdded' => $dateAdded,
+ 'dateModified' => $dateModified,
+ 'version' => $version
+ )
+ );
+ }
+ catch (Exception $e) {
+ Zotero_DB::rollback();
+ throw ($e);
+ }
+
+ // If successful, set values in object
+ if (!$this->id) {
+ $this->id = $tagID;
+ }
+ if (!$this->key) {
+ $this->key = $key;
+ }
+
+ $this->init();
+
+ if ($isNew) {
+ Zotero_Tags::cache($this);
+ }
+
+ return $this->id;
+ }
+
+
+ public function getLinkedItems($asKeys=false) {
+ if (!$this->linkedItemsLoaded) {
+ $this->loadLinkedItems();
+ }
+
+ if ($asKeys) {
+ return $this->linkedItems;
+ }
+
+ return array_map(function ($key) {
+ return Zotero_Items::getByLibraryAndKey($this->libraryID, $key);
+ }, $this->linkedItems);
+ }
+
+
+ public function setLinkedItems($newKeys) {
+ if (!$this->linkedItemsLoaded) {
+ $this->loadLinkedItems();
+ }
+
+ if (!is_array($newKeys)) {
+ throw new Exception('$newKeys must be an array');
+ }
+
+ $oldKeys = $this->getLinkedItems(true);
+
+ if (!$newKeys && !$oldKeys) {
+ Z_Core::debug("No linked items added", 4);
+ return false;
+ }
+
+ $addKeys = array_diff($newKeys, $oldKeys);
+ $removeKeys = array_diff($oldKeys, $newKeys);
+
+ // Make sure all new keys exist
+ foreach ($addKeys as $key) {
+ if (!Zotero_Items::existsByLibraryAndKey($this->libraryID, $key)) {
+ // Return a specific error for a wrong-library tag issue
+ // that I can't reproduce
+ throw new Exception("Linked item $key of tag "
+ . "{$this->libraryID}/{$this->key} not found",
+ Z_ERROR_TAG_LINKED_ITEM_NOT_FOUND);
+ }
+ }
+
+ if ($addKeys || $removeKeys) {
+ $this->prepFieldChange('linkedItems');
+ }
+ else {
+ Z_Core::debug('Linked items not changed', 4);
+ return false;
+ }
+
+ $this->linkedItems = $newKeys;
+ return true;
+ }
+
+
+ public function serialize() {
+ $obj = array(
+ 'primary' => array(
+ 'tagID' => $this->id,
+ 'dateAdded' => $this->dateAdded,
+ 'dateModified' => $this->dateModified,
+ 'key' => $this->key
+ ),
+ 'name' => $this->name,
+ 'type' => $this->type,
+ 'linkedItems' => $this->getLinkedItems(true),
+ );
+
+ return $obj;
+ }
+
+
+ public function toResponseJSON() {
+ if (!$this->loaded) {
+ $this->load();
+ }
+
+ $json = [
+ 'tag' => $this->name
+ ];
+
+ // 'links'
+ $json['links'] = [
+ 'self' => [
+ 'href' => Zotero_API::getTagURI($this),
+ 'type' => 'application/json'
+ ],
+ 'alternate' => [
+ 'href' => Zotero_URI::getTagURI($this, true),
+ 'type' => 'text/html'
+ ]
+ ];
+
+ // 'library'
+ // Don't bother with library for tags
+ //$json['library'] = Zotero_Libraries::toJSON($this->libraryID);
+
+ // 'meta'
+ $json['meta'] = [
+ 'type' => $this->type,
+ 'numItems' => isset($fixedValues['numItems'])
+ ? $fixedValues['numItems']
+ : sizeOf($this->getLinkedItems(true))
+ ];
+
+ return $json;
+ }
+
+
+ public function toJSON() {
+ if (!$this->loaded) {
+ $this->load();
+ }
+
+ $arr['tag'] = $this->name;
+ $arr['type'] = $this->type;
+
+ return $arr;
+ }
+
+
+ /**
+ * Converts a Zotero_Tag object to a SimpleXMLElement Atom object
+ *
+ * @return SimpleXMLElement Tag data as SimpleXML element
+ */
+ public function toAtom($queryParams, $fixedValues=null) {
+ if (!empty($queryParams['content'])) {
+ $content = $queryParams['content'];
+ }
+ else {
+ $content = array('none');
+ }
+ // TEMP: multi-format support
+ $content = $content[0];
+
+ $xml = new SimpleXMLElement(
+ ''
+ . ''
+ );
+
+ $xml->title = $this->name;
+
+ $author = $xml->addChild('author');
+ $author->name = Zotero_Libraries::getName($this->libraryID);
+ $author->uri = Zotero_URI::getLibraryURI($this->libraryID, true);
+
+ $xml->id = Zotero_URI::getTagURI($this);
+
+ $xml->published = Zotero_Date::sqlToISO8601($this->dateAdded);
+ $xml->updated = Zotero_Date::sqlToISO8601($this->dateModified);
+
+ $link = $xml->addChild("link");
+ $link['rel'] = "self";
+ $link['type'] = "application/atom+xml";
+ $link['href'] = Zotero_API::getTagURI($this);
+
+ $link = $xml->addChild('link');
+ $link['rel'] = 'alternate';
+ $link['type'] = 'text/html';
+ $link['href'] = Zotero_URI::getTagURI($this, true);
+
+ // Count user's linked items
+ if (isset($fixedValues['numItems'])) {
+ $numItems = $fixedValues['numItems'];
+ }
+ else {
+ $numItems = sizeOf($this->getLinkedItems(true));
+ }
+ $xml->addChild(
+ 'zapi:numItems',
+ $numItems,
+ Zotero_Atom::$nsZoteroAPI
+ );
+
+ if ($content == 'html') {
+ $xml->content['type'] = 'xhtml';
+
+ $contentXML = new SimpleXMLElement("");
+ $contentXML->addAttribute(
+ "xmlns", Zotero_Atom::$nsXHTML
+ );
+ $fNode = dom_import_simplexml($xml->content);
+ $subNode = dom_import_simplexml($contentXML);
+ $importedNode = $fNode->ownerDocument->importNode($subNode, true);
+ $fNode->appendChild($importedNode);
+ }
+ else if ($content == 'json') {
+ $xml->content['type'] = 'application/json';
+ $xml->content = Zotero_Utilities::formatJSON($this->toJSON());
+ }
+
+ return $xml;
+ }
+
+
+ private function load() {
+ $libraryID = $this->libraryID;
+ $id = $this->id;
+ $key = $this->key;
+
+ if (!$libraryID) {
+ throw new Exception("Library ID not set");
+ }
+
+ if (!$id && !$key) {
+ throw new Exception("ID or key not set");
+ }
+
+ // Cache tag data for the entire library
+ if (true) {
+ if ($id) {
+ Z_Core::debug("Loading data for tag $this->libraryID/$this->id");
+ $row = Zotero_Tags::getPrimaryDataByID($libraryID, $id);
+ }
+ else {
+ Z_Core::debug("Loading data for tag $this->libraryID/$this->key");
+ $row = Zotero_Tags::getPrimaryDataByKey($libraryID, $key);
+ }
+
+ $this->loaded = true;
+
+ if (!$row) {
+ return;
+ }
+
+ if ($row['libraryID'] != $libraryID) {
+ throw new Exception("libraryID {$row['libraryID']} != $this->libraryID");
+ }
+
+ foreach ($row as $key=>$val) {
+ $this->$key = $val;
+ }
+ }
+ // Load tag row individually
+ else {
+ // Use cached check for existence if possible
+ if ($libraryID && $key) {
+ if (!Zotero_Tags::existsByLibraryAndKey($libraryID, $key)) {
+ $this->loaded = true;
+ return;
+ }
+ }
+
+ $shardID = Zotero_Shards::getByLibraryID($libraryID);
+
+ $sql = Zotero_Tags::getPrimaryDataSQL();
+ if ($id) {
+ $sql .= "tagID=?";
+ $stmt = Zotero_DB::getStatement($sql, false, $shardID);
+ $data = Zotero_DB::rowQueryFromStatement($stmt, $id);
+ }
+ else {
+ $sql .= "libraryID=? AND `key`=?";
+ $stmt = Zotero_DB::getStatement($sql, false, $shardID);
+ $data = Zotero_DB::rowQueryFromStatement($stmt, array($libraryID, $key));
+ }
+
+ $this->loaded = true;
+
+ if (!$data) {
+ return;
+ }
+
+ if ($data['libraryID'] != $libraryID) {
+ throw new Exception("libraryID {$data['libraryID']} != $libraryID");
+ }
+
+ foreach ($data as $k=>$v) {
+ $this->$k = $v;
+ }
+ }
+ }
+
+
+ private function loadLinkedItems() {
+ Z_Core::debug("Loading linked items for tag $this->id");
+
+ if (!$this->id && !$this->key) {
+ $this->linkedItemsLoaded = true;
+ return;
+ }
+
+ if (!$this->loaded) {
+ $this->load();
+ }
+
+ if (!$this->id) {
+ $this->linkedItemsLoaded = true;
+ return;
+ }
+
+ $sql = "SELECT `key` FROM itemTags JOIN items USING (itemID) WHERE tagID=?";
+ $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
+ $keys = Zotero_DB::columnQueryFromStatement($stmt, $this->id);
+
+ $this->linkedItems = $keys ? $keys : array();
+ $this->linkedItemsLoaded = true;
+ }
+
+
+ private function checkValue($field, $value) {
+ if (!property_exists($this, $field)) {
+ trigger_error("Invalid property '$field'", E_USER_ERROR);
+ }
+
+ // Data validation
+ switch ($field) {
+ case 'id':
+ case 'libraryID':
+ if (!Zotero_Utilities::isPosInt($value)) {
+ $this->invalidValueError($field, $value);
+ }
+ break;
+
+ case 'key':
+ // 'I' used to exist in client
+ if (!preg_match('/^[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}$/', $value)) {
+ $this->invalidValueError($field, $value);
+ }
+ break;
+
+ case 'dateAdded':
+ case 'dateModified':
+ if (!preg_match("/^[0-9]{4}\-[0-9]{2}\-[0-9]{2} ([0-1][0-9]|[2][0-3]):([0-5][0-9]):([0-5][0-9])$/", $value)) {
+ $this->invalidValueError($field, $value);
+ }
+ break;
+
+ case 'name':
+ if (mb_strlen($value) > Zotero_Tags::$maxLength) {
+ throw new Exception("Tag '" . $value . "' too long", Z_ERROR_TAG_TOO_LONG);
+ }
+ break;
+ }
+ }
+
+
+ private function prepFieldChange($field) {
+ $this->changed[$field] = true;
+
+ // Save a copy of the data before changing
+ // TODO: only save previous data if tag exists
+ if ($this->id && $this->exists() && !$this->previousData) {
+ $this->previousData = $this->serialize();
+ }
+ }
+
+
+ private function invalidValueError($field, $value) {
+ trigger_error("Invalid '$field' value '$value'", E_USER_ERROR);
+ }
+}
+?>
\ No newline at end of file
diff --git a/model/old_Tags.inc.php b/model/old_Tags.inc.php
new file mode 100644
index 000000000..763c810ce
--- /dev/null
+++ b/model/old_Tags.inc.php
@@ -0,0 +1,269 @@
+
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ This file is part of the Zotero Data Server.
+
+ Copyright © 2010 Center for History and New Media
+ George Mason University, Fairfax, Virginia, USA
+ http://zotero.org
+
+ This program 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.
+
+ This program 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 this program. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+class Zotero_Tags extends Zotero_ClassicDataObjects {
+ public static $maxLength = 255;
+
+ protected static $ZDO_object = 'tag';
+
+ protected static $primaryFields = array(
+ 'id' => 'tagID',
+ 'libraryID' => '',
+ 'key' => '',
+ 'name' => '',
+ 'type' => '',
+ 'dateAdded' => '',
+ 'dateModified' => '',
+ 'version' => ''
+ );
+
+ private static $tagsByID = array();
+ private static $namesByHash = array();
+
+ /*
+ * Returns a tag and type for a given tagID
+ */
+ public static function get($libraryID, $tagID, $skipCheck=false) {
+ if (!$libraryID) {
+ throw new Exception("Library ID not provided");
+ }
+
+ if (!$tagID) {
+ throw new Exception("Tag ID not provided");
+ }
+
+ if (isset(self::$tagsByID[$tagID])) {
+ return self::$tagsByID[$tagID];
+ }
+
+ if (!$skipCheck) {
+ $sql = 'SELECT COUNT(*) FROM tags WHERE tagID=?';
+ $result = Zotero_DB::valueQuery($sql, $tagID, Zotero_Shards::getByLibraryID($libraryID));
+ if (!$result) {
+ return false;
+ }
+ }
+
+ $tag = new Zotero_Tag;
+ $tag->libraryID = $libraryID;
+ $tag->id = $tagID;
+
+ self::$tagsByID[$tagID] = $tag;
+ return self::$tagsByID[$tagID];
+ }
+
+
+ /*
+ * Returns tagID for this tag
+ */
+ public static function getID($libraryID, $name, $type, $caseInsensitive=false) {
+ if (!$libraryID) {
+ throw new Exception("Library ID not provided");
+ }
+
+ $name = trim($name);
+ $type = (int) $type;
+
+ // TODO: cache
+
+ $sql = "SELECT tagID FROM tags WHERE ";
+ if ($caseInsensitive) {
+ $sql .= "LOWER(name)=?";
+ $params = [strtolower($name)];
+ }
+ else {
+ $sql .= "name=?";
+ $params = [$name];
+ }
+ $sql .= " AND type=? AND libraryID=?";
+ array_push($params, $type, $libraryID);
+ $tagID = Zotero_DB::valueQuery($sql, $params, Zotero_Shards::getByLibraryID($libraryID));
+
+ return $tagID;
+ }
+
+
+ /*
+ * Returns array of all tagIDs for this tag (of all types)
+ */
+ public static function getIDs($libraryID, $name, $caseInsensitive=false) {
+ // Default empty library
+ if ($libraryID === 0) return [];
+
+ $sql = "SELECT tagID FROM tags WHERE libraryID=? AND name";
+ if ($caseInsensitive) {
+ $sql .= " COLLATE utf8mb4_unicode_ci ";
+ }
+ $sql .= "=?";
+ $tagIDs = Zotero_DB::columnQuery($sql, array($libraryID, $name), Zotero_Shards::getByLibraryID($libraryID));
+ if (!$tagIDs) {
+ return array();
+ }
+ return $tagIDs;
+ }
+
+
+ public static function search($libraryID, $params) {
+ $results = array('results' => array(), 'total' => 0);
+
+ // Default empty library
+ if ($libraryID === 0) {
+ return $results;
+ }
+
+ $shardID = Zotero_Shards::getByLibraryID($libraryID);
+
+ $sql = "SELECT SQL_CALC_FOUND_ROWS DISTINCT tagID FROM tags "
+ . "JOIN itemTags USING (tagID) WHERE libraryID=? ";
+ $sqlParams = array($libraryID);
+
+ // Pass a list of tagIDs, for when the initial search is done via SQL
+ $tagIDs = !empty($params['tagIDs']) ? $params['tagIDs'] : array();
+ // Filter for specific tags with "?tag=foo || bar"
+ $tagNames = [];
+ if (!empty($params['tag'])) {
+ // tag=foo&tag=bar (AND) doesn't make sense in this context
+ if (is_array($params['tag'])) {
+ throw new Exception("Cannot specify 'tag' more than once", Z_ERROR_INVALID_INPUT);
+ }
+ $tagNames = explode(' || ', $params['tag']);
+ }
+ // Filter for tags associated with a set of items
+ $itemIDs = $params['itemIDs'] ?? [];
+
+ if ($tagIDs) {
+ $sql .= "AND tagID IN ("
+ . implode(', ', array_fill(0, sizeOf($tagIDs), '?'))
+ . ") ";
+ $sqlParams = array_merge($sqlParams, $tagIDs);
+ }
+
+ if ($tagNames) {
+ $sql .= "AND `name` IN ("
+ . implode(', ', array_fill(0, sizeOf($tagNames), '?'))
+ . ") ";
+ $sqlParams = array_merge($sqlParams, $tagNames);
+ }
+
+ if ($itemIDs) {
+ $sql .= "AND itemID IN ("
+ . implode(', ', array_map(function ($itemID) {
+ return (int) $itemID;
+ }, $itemIDs))
+ . ") ";
+ }
+
+ if (!empty($params['q'])) {
+ if (!is_array($params['q'])) {
+ $params['q'] = array($params['q']);
+ }
+ foreach ($params['q'] as $q) {
+ $sql .= "AND name LIKE ? ";
+ if ($params['qmode'] == 'startswith') {
+ $sqlParams[] = "$q%";
+ }
+ else {
+ $sqlParams[] = "%$q%";
+ }
+ }
+ }
+
+ $tagTypeSets = Zotero_API::getSearchParamValues($params, 'tagType');
+ if ($tagTypeSets) {
+ $positives = array();
+ $negatives = array();
+
+ foreach ($tagTypeSets as $set) {
+ if ($set['negation']) {
+ $negatives = array_merge($negatives, $set['values']);
+ }
+ else {
+ $positives = array_merge($positives, $set['values']);
+ }
+ }
+
+ if ($positives) {
+ $sql .= "AND type IN (" . implode(',', array_fill(0, sizeOf($positives), '?')) . ") ";
+ $sqlParams = array_merge($sqlParams, $positives);
+ }
+
+ if ($negatives) {
+ $sql .= "AND type NOT IN (" . implode(',', array_fill(0, sizeOf($negatives), '?')) . ") ";
+ $sqlParams = array_merge($sqlParams, $negatives);
+ }
+ }
+
+ if (!empty($params['since'])) {
+ $sql .= "AND version > ? ";
+ $sqlParams[] = $params['since'];
+ }
+
+ if (!empty($params['sort'])) {
+ $order = $params['sort'];
+ if ($order == 'title') {
+ // Force a case-insensitive sort
+ $sql .= "ORDER BY name COLLATE utf8mb4_unicode_ci ";
+ }
+ else if ($order == 'numItems') {
+ $sql .= "GROUP BY tags.tagID ORDER BY COUNT(tags.tagID)";
+ }
+ else {
+ $sql .= "ORDER BY $order ";
+ }
+ if (!empty($params['direction'])) {
+ $sql .= " " . $params['direction'] . " ";
+ }
+ }
+
+ if (!empty($params['limit'])) {
+ $sql .= "LIMIT ?, ?";
+ $sqlParams[] = $params['start'] ? $params['start'] : 0;
+ $sqlParams[] = $params['limit'];
+ }
+
+ $ids = Zotero_DB::columnQuery($sql, $sqlParams, $shardID);
+
+ $results['total'] = Zotero_DB::valueQuery("SELECT FOUND_ROWS()", false, $shardID);
+ if ($ids) {
+ $tags = array();
+ foreach ($ids as $id) {
+ $tags[] = Zotero_Tags::get($libraryID, $id);
+ }
+ $results['results'] = $tags;
+ }
+
+ return $results;
+ }
+
+
+ public static function cache(Zotero_Tag $tag) {
+ if (isset($tagsByID[$tag->id])) {
+ error_log("Tag $tag->id is already cached");
+ }
+
+ self::$tagsByID[$tag->id] = $tag;
+ }
+}
\ No newline at end of file