diff --git a/controllers/ApiController.php b/controllers/ApiController.php index b02bb4e0..2d46a9ea 100644 --- a/controllers/ApiController.php +++ b/controllers/ApiController.php @@ -366,6 +366,10 @@ public function init($extra) { } $this->objectLibraryID = Zotero_Groups::getLibraryIDFromGroupID($this->objectGroupID); } + // Temporarily record shardID in GLOBALS so we can access it in header.inc.php + if (isset($this->objectLibraryID)) { + define('Z_TEMP_SHARD_MIGRATED', in_array(Zotero_Shards::getByLibraryID($this->objectLibraryID), $GLOBALS['updatedShards'])); + } $apiVersion = !empty($_SERVER['HTTP_ZOTERO_API_VERSION']) ? (int) $_SERVER['HTTP_ZOTERO_API_VERSION'] diff --git a/controllers/ItemsController.php b/controllers/ItemsController.php index d3a6e489..c0c96745 100644 --- a/controllers/ItemsController.php +++ b/controllers/ItemsController.php @@ -378,19 +378,23 @@ public function items() { if (!$tagIDs) { $this->e404("Tag not found"); } - - foreach ($tagIDs as $tagID) { - $tag = new Zotero_Tag; - $tag->libraryID = $this->objectLibraryID; - $tag->id = $tagID; - // Use a real tag name, in case case differs - if (!$title) { - $title = "Items of Tag ‘" . $tag->name . "’"; + if (Z_TEMP_SHARD_MIGRATED) { + $linkedItemKeys = Zotero_Tags::loadLinkedItemsKeys($this->objectLibraryID, $this->scopeObjectName); + $itemKeys = array_merge($itemKeys, $linkedItemKeys); + } + else { + foreach ($tagIDs as $tagID) { + $tag = new Zotero_Tag; + $tag->libraryID = $this->objectLibraryID; + $tag->id = $tagID; + // Use a real tag name, in case case differs + if (!$title) { + $title = "Items of Tag ‘" . $tag->name . "’"; + } + $itemKeys = array_merge($itemKeys, $tag->getLinkedItems(true)); } - $itemKeys = array_merge($itemKeys, $tag->getLinkedItems(true)); } $itemKeys = array_unique($itemKeys); - break; default: diff --git a/controllers/TagsController.php b/controllers/TagsController.php index dad6a482..428a5f49 100644 --- a/controllers/TagsController.php +++ b/controllers/TagsController.php @@ -172,6 +172,14 @@ public function tags() { } $title = "Tags of '" . $item->getDisplayTitle() . "'"; $tagIDs = $item->getTags(true); + if (Z_TEMP_SHARD_MIGRATED) { + $tags = $item->getTags(true); + $tagIDs = array_map(function($tag) { + return $tag->id; + }, $tags); + } else { + $tagIDs = $item->getTags(true); + } break; default: @@ -187,11 +195,22 @@ public function tags() { $tagNames = !empty($this->queryParams['tag']) ? explode(' || ', $this->queryParams['tag']): array(); Zotero_DB::beginTransaction(); - foreach ($tagNames as $tagName) { - $tagIDs = Zotero_Tags::getIDs($this->objectLibraryID, $tagName); - foreach ($tagIDs as $tagID) { - $tag = Zotero_Tags::get($this->objectLibraryID, $tagID, true); - Zotero_Tags::delete($this->objectLibraryID, $tag->key, $this->objectUserID); + // Different delete behavior depending on if we are on migrated shard or not + // because after migration $tag->key does not exist + if (Z_TEMP_SHARD_MIGRATED) { + $tagIDs = []; + foreach ($tagNames as $tagName) { + $tagIDs = array_merge($tagIDs, Zotero_Tags::getIDs($this->objectLibraryID, $tagName)); + } + Zotero_Tags::bulkDelete($this->objectLibraryID, null, $tagIDs); + } + else { + foreach ($tagNames as $tagName) { + $tagIDs = Zotero_Tags::getIDs($this->objectLibraryID, $tagName); + foreach ($tagIDs as $tagID) { + $tag = Zotero_Tags::get($this->objectLibraryID, $tagID, true); + Zotero_Tags::delete($this->objectLibraryID, $tag->key, $this->objectUserID); + } } } Zotero_DB::commit(); diff --git a/include/header.inc.php b/include/header.inc.php index 11ce7563..ec06dc1c 100644 --- a/include/header.inc.php +++ b/include/header.inc.php @@ -42,8 +42,23 @@ function zotero_autoload($className) { else { $auth = false; } - - $path = Z_ENV_BASE_PATH . 'model/'; + $updatedShards = $GLOBALS['updatedShards']; + $newFiles = [ + "Item.inc.php", + "Items.inc.php", "Cite.inc.php", + "Library.inc.php", + "Creator.inc.php", + "Creators.inc.php", + "Tag.inc.php", + "Tags.inc.php", + "TagsController.php" + ]; + if (defined('Z_TEMP_SHARD_MIGRATED') && !Z_TEMP_SHARD_MIGRATED && in_array($fileName, $newFiles)) { + $path = Z_ENV_BASE_PATH . 'model/old_'; + } + else { + $path = Z_ENV_BASE_PATH . 'model/'; + } if ($auth) { $path .= 'auth/'; } @@ -74,7 +89,7 @@ function zotero_autoload($className) { return; } } - +$GLOBALS['updatedShards'] = [1]; spl_autoload_register('zotero_autoload'); // Read in configuration variables diff --git a/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects b/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects new file mode 100755 index 00000000..2e5f4cc9 --- /dev/null +++ b/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects @@ -0,0 +1,71 @@ +#!/usr/local/bin/php -d mysqlnd.net_read_timeout=86400 += ? AND shardID <= ? ORDER BY shardID", [$shardHostID, $startShard, $stopShard]); +foreach ($shardIDs as $shardID) { + echo "Shard: $shardID\n"; + + echo "Setting shard to readonly\n"; + Zotero_DB::query("UPDATE shards SET state='readonly' WHERE shardID=?", $shardID); + + echo "Waiting 60 seconds for requests to stop\n"; + sleep(60); + + // Creators + echo "Migrating creators\n"; + // Drop foreign key constraint + Zotero_Admin_DB::query("ALTER TABLE `itemCreators` DROP CONSTRAINT `itemCreators_ibfk_1`", false, $shardID); + Zotero_Admin_DB::query("ALTER TABLE `itemCreators` DROP CONSTRAINT `itemCreators_ibfk_2`", false, $shardID); + + // Create new itemCreators table + Zotero_Admin_DB::query("CREATE TABLE `itemCreatorsNew` (`itemID` BIGINT UNSIGNED NOT NULL, `firstName` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `lastName` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `fieldMode` tinyint(1) UNSIGNED DEFAULT NULL, `creatorTypeID` smallint(5) UNSIGNED NOT NULL, `orderIndex` smallint(5) UNSIGNED NOT NULL, PRIMARY KEY (`itemID`, `orderIndex`)) ENGINE=InnoDB DEFAULT CHARSET=utf8", false, $shardID); + + // Add foreign key to item constraint + Zotero_Admin_DB::query("ALTER TABLE `itemCreatorsNew` ADD CONSTRAINT `itemCreators_ibfk_1` FOREIGN KEY (`itemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE", false, $shardID); + + // Populate new table with data + Zotero_Admin_DB::query("INSERT INTO itemCreatorsNew (firstName, lastName, fieldMode, itemID, creatorTypeID, orderIndex ) SELECT firstName, lastName, fieldMode, itemID, creatorTypeID, orderIndex from creators INNER JOIN itemCreators USING (creatorID)", false, $shardID); + + // Drop old creators tables + Zotero_Admin_DB::query("DROP TABLE itemCreators", false, $shardID); + Zotero_Admin_DB::query("DROP TABLE creators", false, $shardID); + + // Rename old itemCreators table + Zotero_Admin_DB::query("RENAME TABLE itemCreatorsNew TO itemCreators", false, $shardID); + + // Tags + echo "Migrating tags\n"; + // Drop foreign key constraint + Zotero_Admin_DB::query("ALTER TABLE `itemTags` DROP CONSTRAINT `itemTags_ibfk_1`", false, $shardID); + Zotero_Admin_DB::query("ALTER TABLE `itemTags` DROP CONSTRAINT `itemTags_ibfk_2`", false, $shardID); + + // Create new itemTags table + Zotero_Admin_DB::query("CREATE TABLE `itemTagsNew` ( `tagID` BIGINT UNSIGNED NOT NULL, `itemID` BIGINT UNSIGNED NOT NULL, `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, `type` tinyint(1) unsigned NOT NULL DEFAULT '0', `version` int(10) unsigned NOT NULL DEFAULT '1', PRIMARY KEY (`tagID`, `itemID`), KEY `nameType` (`name`, `type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8", false, $shardID); + + // Add foreign key to item constraint + Zotero_Admin_DB::query("ALTER TABLE `itemTagsNew` ADD CONSTRAINT `itemTags_ibfk_1` FOREIGN KEY (`itemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE", false, $shardID); + + // Populate new table with data + Zotero_Admin_DB::query("INSERT INTO itemTagsNew (tagID, itemID, name, type, version) SELECT tagID, itemID, name, type, version from tags INNER JOIN itemTags USING (tagID)", false, $shardID); + + // Drop old creators tables + Zotero_Admin_DB::query("DROP TABLE itemTags", false, $shardID); + Zotero_Admin_DB::query("DROP TABLE tags", false, $shardID); + + // Rename old itemTags table + Zotero_Admin_DB::query("RENAME TABLE itemTagsNew TO itemTags", false, $shardID); + + + echo "Bringing shard back up\n"; + Zotero_DB::query("UPDATE shards SET state='up' WHERE shardID=?", $shardID); + echo "Done with shard $shardID\n\n"; + sleep(1); +} diff --git a/misc/shard.sql b/misc/shard.sql index afb432cd..290b7754 100644 --- a/misc/shard.sql +++ b/misc/shard.sql @@ -330,7 +330,7 @@ CREATE TABLE `syncDeleteLogIDs` ( CREATE TABLE `syncDeleteLogKeys` ( `libraryID` int(10) unsigned NOT NULL, - `objectType` enum('collection','creator','item','relation','search','setting','tag','tagName') NOT NULL, + `objectType` enum('collection','item','relation','search','setting','tag','tagName') NOT NULL, `key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `version` int(10) unsigned NOT NULL DEFAULT '1', diff --git a/model/Cite.inc.php b/model/Cite.inc.php index d285dea7..79a1af80 100644 --- a/model/Cite.inc.php +++ b/model/Cite.inc.php @@ -297,17 +297,17 @@ public static function retrieveItem($zoteroItem) { $authorID = Zotero_CreatorTypes::getPrimaryIDForType($zoteroItem->itemTypeID); $creators = $zoteroItem->getCreators(); foreach ($creators as $creator) { - if ($creator['creatorTypeID'] == $authorID) { + if ($creator->creatorTypeID == $authorID) { $creatorType = "author"; } else { - $creatorType = Zotero_CreatorTypes::getName($creator['creatorTypeID']); + $creatorType = Zotero_CreatorTypes::getName($creator->creatorTypeID); } $creatorType = isset(self::$zoteroNameMap[$creatorType]) ? self::$zoteroNameMap[$creatorType] : false; if (!$creatorType) continue; - $nameObj = array('family' => $creator['ref']->lastName, 'given' => $creator['ref']->firstName); + $nameObj = array('family' => $creator->lastName, 'given' => $creator->firstName); if (isset($cslItem[$creatorType])) { $cslItem[$creatorType][] = $nameObj; diff --git a/model/Collection.inc.php b/model/Collection.inc.php index e51af75f..bb25e042 100644 --- a/model/Collection.inc.php +++ b/model/Collection.inc.php @@ -593,7 +593,7 @@ public function getRelations() { * Returns all tags assigned to items in this collection */ public function getTags($asIDs=false) { - $sql = "SELECT tagID FROM tags JOIN itemTags USING (tagID) + $sql = "SELECT tagID FROM itemTags JOIN collectionItems USING (itemID) WHERE collectionID=? ORDER BY name"; $tagIDs = Zotero_DB::columnQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); if (!$tagIDs) { @@ -604,11 +604,7 @@ public function getTags($asIDs=false) { return $tagIDs; } - $tagObjs = array(); - foreach ($tagIDs as $tagID) { - $tag = Zotero_Tags::get($tagID, true); - $tagObjs[] = $tag; - } + $tagObjs = Zotero_Tags::bulkGet($this->libraryID, $tagIDs); return $tagObjs; } @@ -618,7 +614,7 @@ public function getTags($asIDs=false) { * in this collection */ public function getTagItemCounts() { - $sql = "SELECT tagID, COUNT(*) AS numItems FROM tags JOIN itemTags USING (tagID) + $sql = "SELECT tagID, COUNT(*) AS numItems FROM itemTags JOIN collectionItems USING (itemID) WHERE collectionID=? GROUP BY tagID"; $rows = Zotero_DB::query($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); if (!$rows) { diff --git a/model/Creator.inc.php b/model/Creator.inc.php index a60c3268..f9fc1b3d 100644 --- a/model/Creator.inc.php +++ b/model/Creator.inc.php @@ -25,42 +25,33 @@ */ class Zotero_Creator { - private $id; private $libraryID; - private $key; private $firstName = ''; private $lastName = ''; private $shortName = ''; private $fieldMode = 0; - private $birthYear; - private $dateAdded; - private $dateModified; - - private $loaded = false; + private $creatorTypeID; + private $orderIndex; private $changed = 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; - + public function __construct($libraryID, $firstName, $lastName, $fieldMode, $creatorTypeID, $orderIndex) { + $this->libraryID = $libraryID; + $this->firstName = $firstName; + $this->lastName = $lastName; + $this->fieldMode = $fieldMode; + $this->creatorTypeID = $creatorTypeID; + $this->orderIndex = $orderIndex; $this->changed = array(); $props = array( + 'libraryID', 'firstName', 'lastName', 'shortName', 'fieldMode', - 'birthYear', - 'dateAdded', - 'dateModified' + 'creatorTypeID', + 'orderIndex' ); foreach ($props as $prop) { $this->changed[$prop] = false; @@ -69,10 +60,7 @@ private function init() { public function __get($field) { - if (($this->id || $this->key) && !$this->loaded) { - $this->load(true); - } - + if (!property_exists('Zotero_Creator', $field)) { throw new Exception("Zotero_Creator property '$field' doesn't exist"); } @@ -83,12 +71,7 @@ public function __get($field) { public function __set($field, $value) { switch ($field) { - case 'id': case 'libraryID': - case 'key': - if ($this->loaded) { - throw new Exception("Cannot set $field after creator is already loaded"); - } $this->checkValue($field, $value); $this->$field = $value; return; @@ -99,15 +82,6 @@ public function __set($field, $value) { break; } - if ($this->id || $this->key) { - if (!$this->loaded) { - $this->load(true); - } - } - else { - $this->loaded = true; - } - $this->checkValue($field, $value); if ($this->$field !== $value) { @@ -117,188 +91,15 @@ public function __set($field, $value) { } - /** - * Check if creator 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 creators WHERE creatorID=?"; - return !!Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); - } - public function hasChanged() { return in_array(true, array_values($this->changed)); } - public function save($userID=false) { - if (!$this->libraryID) { - trigger_error("Library ID must be set before saving", E_USER_ERROR); - } - - Zotero_Creators::editCheck($this, $userID); - - // If empty, move on - if ($this->firstName === '' && $this->lastName === '') { - throw new Exception('First and last name are empty'); - } - - if ($this->fieldMode == 1 && $this->firstName !== '') { - throw new Exception('First name must be empty in single-field mode'); - } - - if (!$this->hasChanged()) { - Z_Core::debug("Creator $this->id has not changed"); - return false; - } - - Zotero_DB::beginTransaction(); - - try { - $creatorID = $this->id ? $this->id : Zotero_ID::get('creators'); - $isNew = !$this->id; - - Z_Core::debug("Saving creator $this->id"); - - $key = $this->key ? $this->key : Zotero_ID::getKey(); - - $timestamp = Zotero_DB::getTransactionTimestamp(); - - $dateAdded = $this->dateAdded ? $this->dateAdded : $timestamp; - $dateModified = !empty($this->changed['dateModified']) ? $this->dateModified : $timestamp; - - $fields = "firstName=?, lastName=?, fieldMode=?, - libraryID=?, `key`=?, dateAdded=?, dateModified=?, serverDateModified=?"; - $params = array( - $this->firstName, - $this->lastName, - $this->fieldMode, - $this->libraryID, - $key, - $dateAdded, - $dateModified, - $timestamp - ); - $shardID = Zotero_Shards::getByLibraryID($this->libraryID); - - try { - if ($isNew) { - $sql = "INSERT INTO creators SET creatorID=?, $fields"; - $stmt = Zotero_DB::getStatement($sql, true, $shardID); - Zotero_DB::queryFromStatement($stmt, array_merge(array($creatorID), $params)); - - // Remove from delete log if it's there - $sql = "DELETE FROM syncDeleteLogKeys WHERE libraryID=? AND objectType='creator' AND `key`=?"; - Zotero_DB::query($sql, array($this->libraryID, $key), $shardID); - } - else { - $sql = "UPDATE creators SET $fields WHERE creatorID=?"; - $stmt = Zotero_DB::getStatement($sql, true, $shardID); - Zotero_DB::queryFromStatement($stmt, array_merge($params, array($creatorID))); - } - } - catch (Exception $e) { - if (strpos($e->getMessage(), " too long") !== false) { - if (strlen($this->firstName) > 255) { - $name = $this->firstName; - } - else if (strlen($this->lastName) > 255) { - $name = $this->lastName; - } - else { - throw $e; - } - $name = mb_substr($name, 0, 50); - throw new Exception( - "=Creator value '{$name}…' too long", - Z_ERROR_CREATOR_TOO_LONG - ); - } - - throw $e; - } - - // The client updates the mod time of associated items here, but - // we don't, because either A) this is from syncing, where appropriate - // mod times come from the client or B) the change is made through - // $item->setCreator(), which updates the mod time. - // - // If the server started to make other independent creator changes, - // linked items would need to be updated. - - Zotero_DB::commit(); - - Zotero_Creators::cachePrimaryData( - array( - 'id' => $creatorID, - 'libraryID' => $this->libraryID, - 'key' => $key, - 'dateAdded' => $dateAdded, - 'dateModified' => $dateModified, - 'firstName' => $this->firstName, - 'lastName' => $this->lastName, - 'fieldMode' => $this->fieldMode - ) - ); - } - catch (Exception $e) { - Zotero_DB::rollback(); - throw ($e); - } - - // If successful, set values in object - if (!$this->id) { - $this->id = $creatorID; - } - if (!$this->key) { - $this->key = $key; - } - - $this->init(); - - if ($isNew) { - Zotero_Creators::cache($this); - } - - // TODO: invalidate memcache? - - return $this->id; - } - - - public function getLinkedItems() { - if (!$this->id) { - return array(); - } - - $items = array(); - $sql = "SELECT itemID FROM itemCreators WHERE creatorID=?"; - $itemIDs = Zotero_DB::columnQuery( - $sql, - $this->id, - Zotero_Shards::getByLibraryID($this->libraryID) - ); - if (!$itemIDs) { - return $items; - } - foreach ($itemIDs as $itemID) { - $items[] = Zotero_Items::get($this->libraryID, $itemID); - } - return $items; - } - public function equals($creator) { - if (!$this->loaded) { - $this->load(); - } - + public function equals($creator) { return ($creator->firstName === $this->firstName) && ($creator->lastName === $this->lastName) && @@ -306,41 +107,6 @@ public function equals($creator) { } - private function load() { - if (!$this->libraryID) { - throw new Exception("Library ID not set"); - } - - if (!$this->id && !$this->key) { - throw new Exception("ID or key not set"); - } - - if ($this->id) { - //Z_Core::debug("Loading data for creator $this->libraryID/$this->id"); - $row = Zotero_Creators::getPrimaryDataByID($this->libraryID, $this->id); - } - else { - //Z_Core::debug("Loading data for creator $this->libraryID/$this->key"); - $row = Zotero_Creators::getPrimaryDataByKey($this->libraryID, $this->key); - } - - $this->loaded = true; - $this->changed = array(); - - if (!$row) { - return; - } - - if ($row['libraryID'] != $this->libraryID) { - throw new Exception("libraryID {$row['libraryID']} != $this->libraryID"); - } - - foreach ($row as $key=>$val) { - $this->$key = $val; - } - } - - private function checkValue($field, $value) { if (!property_exists($this, $field)) { throw new Exception("Invalid property '$field'"); @@ -348,8 +114,8 @@ private function checkValue($field, $value) { // Data validation switch ($field) { - case 'id': case 'libraryID': + case 'creatorTypeID': if (!Zotero_Utilities::isPosInt($value)) { $this->invalidValueError($field, $value); } @@ -360,19 +126,6 @@ private function checkValue($field, $value) { $this->invalidValueError($field, $value); } break; - - case 'key': - if (!preg_match('/^[23456789ABCDEFGHIJKMNPQRSTUVWXTZ]{8}$/', $value)) { - $this->invalidValueError($field, $value); - } - break; - - case 'dateAdded': - case 'dateModified': - if ($value !== '' && !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; } } diff --git a/model/Creators.inc.php b/model/Creators.inc.php index 4e6d2590..2fb8c3fe 100644 --- a/model/Creators.inc.php +++ b/model/Creators.inc.php @@ -24,21 +24,11 @@ ***** END LICENSE BLOCK ***** */ -class Zotero_Creators extends Zotero_ClassicDataObjects { +class Zotero_Creators { public static $creatorSummarySortLength = 50; protected static $ZDO_object = 'creator'; - protected static $primaryFields = array( - 'id' => 'creatorID', - 'libraryID' => '', - 'key' => '', - 'dateAdded' => '', - 'dateModified' => '', - 'firstName' => '', - 'lastName' => '', - 'fieldMode' => '' - ); private static $fields = array( 'firstName', 'lastName', 'fieldMode' ); @@ -47,94 +37,36 @@ class Zotero_Creators extends Zotero_ClassicDataObjects { private static $maxLastNameLength = 255; private static $creatorsByID = array(); - private static $primaryDataByCreatorID = array(); private static $primaryDataByLibraryAndKey = array(); - - public static function get($libraryID, $creatorID, $skipCheck=false) { - if (!$libraryID) { - throw new Exception("Library ID not set"); - } - - if (!$creatorID) { - throw new Exception("Creator ID not set"); - } - - if (!empty(self::$creatorsByID[$creatorID])) { - return self::$creatorsByID[$creatorID]; - } - - if (!$skipCheck) { - $sql = 'SELECT COUNT(*) FROM creators WHERE creatorID=?'; - $result = Zotero_DB::valueQuery($sql, $creatorID, Zotero_Shards::getByLibraryID($libraryID)); - if (!$result) { - return false; - } - } - - $creator = new Zotero_Creator; - $creator->libraryID = $libraryID; - $creator->id = $creatorID; - - self::$creatorsByID[$creatorID] = $creator; - return self::$creatorsByID[$creatorID]; + public static function bulkDelete($libraryID, $itemID, $creatorOrdersArray) { + $placeholders = implode(', ', array_fill(0, sizeOf($creatorOrdersArray), '?')); + $sql = "DELETE FROM itemCreators WHERE itemID=? AND orderIndex IN ($placeholders)"; + Zotero_DB::query($sql, array_merge([$itemID], $creatorOrdersArray), Zotero_Shards::getByLibraryID($libraryID)); } - - - public static function getCreatorsWithData($libraryID, $creator, $sortByItemCountDesc=false) { - $sql = "SELECT creatorID, firstName, lastName FROM creators "; - if ($sortByItemCountDesc) { - $sql .= "LEFT JOIN itemCreators USING (creatorID) "; - } - $sql .= "WHERE libraryID=? AND firstName = ? " - . "AND lastName = ? AND fieldMode=?"; - if ($sortByItemCountDesc) { - $sql .= " GROUP BY creatorID ORDER BY IFNULL(COUNT(*), 0) DESC"; - } - $rows = Zotero_DB::query( - $sql, - array( - $libraryID, + + public static function bulkInsert($libraryID, $itemID, $creators) { + $placeholdersArray = array(); + $paramList = array(); + foreach ($creators as $creator) { + $placeholdersArray[] = "(?, ?, ?, ?, ?, ?)"; + $paramList = array_merge($paramList, [ + $itemID, $creator->firstName, $creator->lastName, - $creator->fieldMode - ), - Zotero_Shards::getByLibraryID($libraryID) - ); - - // Case-sensitive filter, since the DB columns use a case-insensitive collation and we want - // it to use an index - $rows = array_filter($rows, function ($row) use ($creator) { - return $row['lastName'] == $creator->lastName && $row['firstName'] == $creator->firstName; - }); - - return array_column($rows, 'creatorID'); + $creator->fieldMode, + $creator->creatorTypeID, + $creator->orderIndex, + ]); + } + $placeholdersStr = implode(", ", $placeholdersArray); + $sql = "INSERT INTO itemCreators (itemID, firstName, lastName, fieldMode, creatorTypeID, orderIndex) VALUES $placeholdersStr"; + + $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($libraryID)); + Zotero_DB::queryFromStatement($stmt, $paramList); } -/* - public static function updateLinkedItems($creatorID, $dateModified) { - Zotero_DB::beginTransaction(); - - // TODO: add to notifier, if we have one - //$sql = "SELECT itemID FROM itemCreators WHERE creatorID=?"; - //$changedItemIDs = Zotero_DB::columnQuery($sql, $creatorID); - - // This is very slow in MySQL 5.1.33 -- should be faster in MySQL 6 - //$sql = "UPDATE items SET dateModified=?, serverDateModified=? WHERE itemID IN - // (SELECT itemID FROM itemCreators WHERE creatorID=?)"; - - $sql = "UPDATE items JOIN itemCreators USING (itemID) SET items.dateModified=?, - items.serverDateModified=?, serverDateModifiedMS=? WHERE creatorID=?"; - $timestamp = Zotero_DB::getTransactionTimestamp(); - $timestampMS = Zotero_DB::getTransactionTimestampMS(); - Zotero_DB::query( - $sql, - array($dateModified, $timestamp, $timestampMS, $creatorID) - ); - Zotero_DB::commit(); - } -*/ public static function cache(Zotero_Creator $creator) { if (isset(self::$creatorsByID[$creator->id])) { @@ -143,6 +75,17 @@ public static function cache(Zotero_Creator $creator) { self::$creatorsByID[$creator->id] = $creator; } + + public static function editCheck($obj, $userID=false) { + if (!$userID) { + return true; + } + + if (!Zotero_Libraries::userCanEdit($obj->libraryID, $userID, $obj)) { + throw new Exception("Cannot edit " . self::$objectType + . " in library $obj->libraryID", Z_ERROR_LIBRARY_ACCESS_DENIED); + } + } public static function getLocalizedFieldNames($locale='en-US') { @@ -197,8 +140,6 @@ private static function convertXMLToDataValues(DOMElement $xml) { $dataObj->lastName = $xml->getElementsByTagName('lastName')->item(0)->nodeValue; } - $birthYear = $xml->getElementsByTagName('birthYear')->item(0); - $dataObj->birthYear = $birthYear ? $birthYear->nodeValue : null; return $dataObj; } diff --git a/model/Item.inc.php b/model/Item.inc.php index 2e9bf482..27a8d104 100644 --- a/model/Item.inc.php +++ b/model/Item.inc.php @@ -322,12 +322,12 @@ public function getDisplayTitle($includeAuthorAndDate=false) { $participants = array(); if ($creators) { foreach ($creators as $creator) { - if (($itemTypeID == $itemTypeLetter && $creator['creatorTypeID'] == $creatorTypeRecipient) || - ($itemTypeID == $itemTypeInterview && $creator['creatorTypeID'] == $creatorTypeInterviewer)) { + if (($itemTypeID == $itemTypeLetter && $creator->creatorTypeID == $creatorTypeRecipient) || + ($itemTypeID == $itemTypeInterview && $creator->creatorTypeID == $creatorTypeInterviewer)) { $participants[] = $creator; } - else if (($itemTypeID == $itemTypeLetter && $creator['creatorTypeID'] == $creatorTypeAuthor) || - ($itemTypeID == $itemTypeInterview && $creator['creatorTypeID'] == $creatorTypeInterviewee)) { + else if (($itemTypeID == $itemTypeLetter && $creator->creatorTypeID == $creatorTypeAuthor) || + ($itemTypeID == $itemTypeInterview && $creator->creatorTypeID == $creatorTypeInterviewee)) { $authors[] = $creator; } } @@ -338,7 +338,7 @@ public function getDisplayTitle($includeAuthorAndDate=false) { if ($includeAuthorAndDate) { $names = array(); foreach($authors as $author) { - $names[] = $author['ref']->lastName; + $names[] = $author->lastName; } // TODO: Use same logic as getFirstCreatorSQL() (including "et al.") @@ -351,7 +351,7 @@ public function getDisplayTitle($includeAuthorAndDate=false) { if ($participants) { $names = array(); foreach ($participants as $participant) { - $names[] = $participant['ref']->lastName; + $names[] = $participant->lastName; } switch (sizeOf($names)) { case 1: @@ -437,8 +437,8 @@ public function getDisplayTitle($includeAuthorAndDate=false) { } $creators = $this->getCreators(); - if ($creators && $creators[0]['creatorTypeID'] === $creatorTypeAuthor) { - $strParts[] = $creators[0]['ref']->lastName; + if ($creators && $creators[0]->creatorTypeID === $creatorTypeAuthor) { + $strParts[] = $creators[0]->lastName; } $title = '[' . implode(', ', $strParts) . ']'; @@ -593,14 +593,14 @@ private function setType($itemTypeID, $loadIn=false) { $creators = $this->getCreators(); if ($creators) { foreach ($creators as $orderIndex=>$creator) { - if (Zotero_CreatorTypes::isCustomType($creator['creatorTypeID'])) { + if (Zotero_CreatorTypes::isCustomType($creator->creatorTypeID)) { continue; } - if (!Zotero_CreatorTypes::isValidForItemType($creator['creatorTypeID'], $itemTypeID)) { + if (!Zotero_CreatorTypes::isValidForItemType($creator->creatorTypeID, $itemTypeID)) { // TODO: port // Reset to contributor (creatorTypeID 2), which exists in all - $this->setCreator($orderIndex, $creator['ref'], 2); + $this->setCreator($orderIndex, $creator, 2); } } } @@ -888,6 +888,7 @@ private function getCreatorSummary() { } $itemTypeID = $this->getField('itemTypeID'); + $creators = $this->getCreators(); $creatorTypeIDsToTry = array( @@ -906,7 +907,7 @@ private function getCreatorSummary() { foreach ($creatorTypeIDsToTry as $creatorTypeID) { $loc = array(); foreach ($creators as $orderIndex=>$creator) { - if ($creator['creatorTypeID'] == $creatorTypeID) { + if ($creator->creatorTypeID == $creatorTypeID) { $loc[] = $orderIndex; if (sizeOf($loc) == 3) { @@ -920,17 +921,17 @@ private function getCreatorSummary() { continue 2; case 1: - $creatorSummary = $creators[$loc[0]]['ref']->lastName; + $creatorSummary = $creators[$loc[0]]->lastName; break; case 2: - $creatorSummary = $creators[$loc[0]]['ref']->lastName + $creatorSummary = $creators[$loc[0]]->lastName . $localizedAnd - . $creators[$loc[1]]['ref']->lastName; + . $creators[$loc[1]]->lastName; break; case 3: - $creatorSummary = $creators[$loc[0]]['ref']->lastName . $etAl; + $creatorSummary = $creators[$loc[0]]->lastName . $etAl; break; } @@ -1222,63 +1223,14 @@ public function save($userID=false) { 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(); - + $creatorsArray = []; 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 - ); + $creatorsArray[] = $creator; } - if ($sqlValues) { - $sql = $sql . implode(',', $placeholders); - Zotero_DB::query($sql, $sqlValues, $shardID); - } + Zotero_Creators::bulkInsert($this->libraryID, $itemID, $creatorsArray); + } @@ -1572,21 +1524,7 @@ public function save($userID=false) { 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(); - } + Zotero_Tags::bulkInsert($this->libraryID, $itemID, $this->tags); } // Related items @@ -1764,45 +1702,15 @@ public function save($userID=false) { // 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(); - + + $toAdd = []; 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 - ); + $toAdd[] = $creator; } + Zotero_Creators::bulkDelete($this->libraryID, $this->_id, $indexes); + Zotero_Creators::bulkInsert($this->libraryID, $this->_id, $toAdd); - if ($sqlValues) { - $sql = $sql . implode(',', $placeholders); - Zotero_DB::query($sql, $sqlValues, $shardID); - } } // Deleted item @@ -2236,28 +2144,8 @@ public function save($userID=false) { $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(); - } + Zotero_Tags::bulkInsert($this->_libraryID, $this->_id, $toAdd); + Zotero_Tags::bulkDelete($this->_libraryID, $this->_id, $toRemove); } // Related items @@ -2433,14 +2321,14 @@ public function getCreators() { } - public function setCreator($orderIndex, Zotero_Creator $creator, $creatorTypeID) { + public function setCreator($orderIndex, Zotero_Creator $creator) { if ($this->id && !$this->loaded['creators']) { $this->loadCreators(); } else { $this->loaded['creators'] = true; } - + $creatorTypeID = $creator->creatorTypeID; if (!is_integer($orderIndex)) { throw new Exception("orderIndex must be an integer"); } @@ -2465,19 +2353,13 @@ public function setCreator($orderIndex, Zotero_Creator $creator, $creatorTypeID) $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; + if (!isset($this->creators[$orderIndex]) || !$this->creators[$orderIndex]->equals($creator)) { + $this->creators[$orderIndex] = $creator; + $this->creators[$orderIndex]->creatorTypeID = $creatorTypeID; + $this->changed['creators'][$orderIndex] = true; + return true; } - - $this->creators[$orderIndex]['ref'] = $creator; - $this->creators[$orderIndex]['creatorTypeID'] = $creatorTypeID; - $this->changed['creators'][$orderIndex] = true; - return true; + return false; } @@ -3708,28 +3590,11 @@ public function numTags() { * * @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; + public function getTags() { + if ($this->id && !$this->loaded['tags']) { + $this->loadTags(); } - return $tagObjs; + return $this->tags; } @@ -3742,7 +3607,7 @@ public function setTags($newTags) { if (!$this->loaded['tags']) { $this->loadTags(); } - + // Ignore empty tags $newTags = array_filter($newTags, function ($tag) { if (is_string($tag)) { @@ -3756,21 +3621,49 @@ public function setTags($newTags) { } $this->storePreviousData('tags'); - $this->tags = []; + + $existingTagNames = array_map(function($tag) { + return $tag->name; + }, $this->tags); + $foundNames = []; + $tagArray = []; + $changed = false; 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; + $name = trim($newTag); + $type = 0; } else { - $obj->name = trim($newTag->tag); - $obj->type = (int) isset($newTag->type) ? $newTag->type : 0; + $name = trim($newTag->tag); + $type = (int) isset($newTag->type) ? $newTag->type : 0; + } + $skip = false; + foreach($this->tags as $existingTag) { + if ($existingTag->name == $name && $existingTag->type == $type) { + $skip = true; + $foundNames[] = $existingTag->name; + break; + } + } + if ($skip) { + continue; } - $this->tags[] = $obj; + $version = Zotero_Libraries::getUpdatedVersion($this->libraryID); + $tagArray[] = new Zotero_Tag(null, $this->libraryID, $name, $type, $version); + $changed = true; + } + $this->tags = array_merge($this->tags, $tagArray); + if ($changed) { + $this->changed['tags'] = $changed; + } + $toRemove = array_diff($existingTagNames, $foundNames); + if (sizeof($this->tags) !== sizeof($newTags)) { + $this->tags = array_filter($this->tags, function($existingTag) use ($toRemove) { + return !in_array($existingTag->name, $toRemove); + }); + $this->changed['tags'] = true; } - $this->changed['tags'] = true; } @@ -3854,12 +3747,12 @@ public function toHTML(bool $asSimpleXML, $requestParams) { $displayText = ''; foreach ($creators as $creator) { // Two fields - if ($creator['ref']->fieldMode == 0) { - $displayText = $creator['ref']->firstName . ' ' . $creator['ref']->lastName; + if ($creator->fieldMode == 0) { + $displayText = $creator->firstName . ' ' . $creator->lastName; } // Single field - else if ($creator['ref']->fieldMode == 1) { - $displayText = $creator['ref']->lastName; + else if ($creator->fieldMode == 1) { + $displayText = $creator->lastName; } else { // TODO @@ -3868,7 +3761,7 @@ public function toHTML(bool $asSimpleXML, $requestParams) { Zotero_Atom::addHTMLRow( $html, "creator", - Zotero_CreatorTypes::getLocalizedString($creator['creatorTypeID']), + Zotero_CreatorTypes::getLocalizedString($creator->creatorTypeID), trim($displayText) ); } @@ -4442,16 +4335,16 @@ public function toJSON($asArray=false, $requestParams=array(), $includeEmpty=fal $creators = $this->getCreators(); foreach ($creators as $creator) { $c = array(); - $c['creatorType'] = Zotero_CreatorTypes::getName($creator['creatorTypeID']); + $c['creatorType'] = Zotero_CreatorTypes::getName($creator->creatorTypeID); // Single-field mode - if ($creator['ref']->fieldMode == 1) { - $c['name'] = $creator['ref']->lastName; + if ($creator->fieldMode == 1) { + $c['name'] = $creator->lastName; } // Two-field mode else { - $c['firstName'] = $creator['ref']->firstName; - $c['lastName'] = $creator['ref']->lastName; + $c['firstName'] = $creator->firstName; + $c['lastName'] = $creator->lastName; } $arr['creators'][] = $c; } @@ -4716,7 +4609,8 @@ private function getNoteHash() { protected function loadCreators($reload = false) { if ($this->loaded['creators'] && !$reload) return; - + $cache_used = false; + if (!$this->id) { trigger_error('Item ID not set for item before attempting to load creators', E_USER_ERROR); } @@ -4739,8 +4633,7 @@ protected function loadCreators($reload = false) { $creators = false; } if ($creators === false) { - $sql = "SELECT creatorID, creatorTypeID, orderIndex FROM itemCreators - WHERE itemID=? ORDER BY orderIndex"; + $sql = "SELECT * 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); @@ -4748,6 +4641,9 @@ protected function loadCreators($reload = false) { Z_Core::$MC->set($cacheKey, $creators ? $creators : array()); } } + else { + $cache_used = true; + } $this->creators = []; $this->loaded['creators'] = true; @@ -4756,17 +4652,21 @@ protected function loadCreators($reload = false) { if (!$creators) { return; } - + + if ($cache_used) { + // Check if the cached data is still valid - without creatorID + // $creatorsNotFound = Zotero_Creators::idsDoNotExist($this->libraryID, $creators); + + // foreach($creatorsNotFound as $missingCreator) { + // Z_Core::$MC->delete($cacheKey); + // throw new Exception("Creator {$creator['creatorID']} not found"); + // } + } + 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 - ); + $creatorObj = new Zotero_Creator($this->_libraryID, $creator['firstName'], $creator['lastName'], $creator['fieldMode'], $creator['creatorTypeID'], $creator['orderIndex']); + + $this->creators[$creator['orderIndex']] = $creatorObj; } } @@ -4803,14 +4703,14 @@ protected function loadTags($reload = false) { 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 = "SELECT * FROM itemTags WHERE itemID=?"; + $tags = Zotero_DB::query( $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); + if ($tags) { + foreach ($tags as $tag) { + $this->tags[] = new Zotero_Tag($tag['tagID'], $this->libraryID, $tag['name'], $tag['type'], $tag['version']); } } $this->loaded['tags'] = true; diff --git a/model/Items.inc.php b/model/Items.inc.php index df08d0c6..95a75247 100644 --- a/model/Items.inc.php +++ b/model/Items.inc.php @@ -186,8 +186,7 @@ public static function search($libraryID, $onlyTopLevel = false, array $params = 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) "; + $sql .= "LEFT JOIN itemCreators IC ON (IC.itemID=I.itemID) "; // Pull in dates $dateFieldIDs = array_merge( @@ -419,21 +418,10 @@ public static function search($libraryID, $onlyTopLevel = false, array $params = $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); - + $lowercaseTags = array_map('strtolower', $set['values']); $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); + . "WHERE LOWER(itemTags.name) IN (" . implode(',', array_fill(0, sizeOf($set['values']), '?')) . ")"; + $ids = Zotero_DB::columnQuery($tmpSQL, $lowercaseTags, $shardID); if (!$ids) { // If no negative tags, skip this tag set @@ -447,7 +435,7 @@ public static function search($libraryID, $onlyTopLevel = false, array $params = $ids = $ids ? $ids : array(); $sql2 .= " AND itemID " . ($set['negation'] ? "NOT " : "") . " IN (" - . implode(',', array_fill(0, sizeOf($ids), '?')) . ")"; + . implode(',', array_fill(0, sizeof($ids), '?')) . ")"; $sqlParams2 = array_merge($sqlParams2, $ids); } @@ -1706,6 +1694,7 @@ public static function updateFromJSON(Zotero_Item $item, } $orderIndex = -1; + $creatorsToAdd = []; foreach ($val as $newCreatorData) { // JSON uses 'name' and 'firstName'/'lastName', // so switch to just 'firstName'/'lastName' @@ -1728,49 +1717,12 @@ public static function updateFromJSON(Zotero_Item $item, $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); + + // Make creator object + $newCreator = new Zotero_Creator($item->libraryID, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode, $newCreatorTypeID, $orderIndex); + $item->setCreator($orderIndex, $newCreator); } + // Remove all existing creators above the current index if ($exists && $indexes = array_keys($item->getCreators())) { diff --git a/model/Libraries.inc.php b/model/Libraries.inc.php index 7b73d130..9bc0e5c1 100644 --- a/model/Libraries.inc.php +++ b/model/Libraries.inc.php @@ -373,7 +373,7 @@ public static function clearAllData($libraryID) { Zotero_DB::beginTransaction(); $tables = array( - 'collections', 'creators', 'items', 'relations', 'savedSearches', 'tags', + 'collections', 'items', 'relations', 'savedSearches', 'syncDeleteLogIDs', 'syncDeleteLogKeys', 'settings' ); diff --git a/model/Tag.inc.php b/model/Tag.inc.php index 68d10de0..c2f79175 100644 --- a/model/Tag.inc.php +++ b/model/Tag.inc.php @@ -27,42 +27,26 @@ 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 $linkedItemsCount; + - private function init() { - $this->loaded = false; - - $this->previousData = array(); - $this->linkedItemsLoaded = false; + public function __construct($id, $libraryID, $name, $type, $version) { + $this->__set("id", $id); + $this->__set("libraryID", $libraryID); + $this->__set("name", $name); + $this->__set("type", $type); + $this->__set("version", $version); $this->changed = array(); $props = array( 'name', 'type', - 'dateAdded', - 'dateModified', 'linkedItems' ); foreach ($props as $prop) { @@ -70,14 +54,11 @@ private function init() { } } - 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 null; + //throw new Exception("Zotero_Tag property '$field' doesn't exist"); } return $this->$field; @@ -85,384 +66,24 @@ public function __get($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); + if ($this->$field !== $value) { $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; + public function getLinkedItemsCount() { + if (!$this->linkedItemsCount) { + $this->loadLinkedItemsCount(); } - - $this->linkedItems = $newKeys; - return true; + return $this->linkedItemsCount; } - 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 @@ -489,7 +110,7 @@ public function toResponseJSON() { 'type' => $this->type, 'numItems' => isset($fixedValues['numItems']) ? $fixedValues['numItems'] - : sizeOf($this->getLinkedItems(true)) + : $this->getLinkedItemsCount() ]; return $json; @@ -497,10 +118,6 @@ public function toResponseJSON() { public function toJSON() { - if (!$this->loaded) { - $this->load(); - } - $arr['tag'] = $this->name; $arr['type'] = $this->type; @@ -538,7 +155,6 @@ public function toAtom($queryParams, $fixedValues=null) { $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"; @@ -555,7 +171,7 @@ public function toAtom($queryParams, $fixedValues=null) { $numItems = $fixedValues['numItems']; } else { - $numItems = sizeOf($this->getLinkedItems(true)); + $numItems = sizeOf($this->getLinkedItemsCount()); } $xml->addChild( 'zapi:numItems', @@ -584,108 +200,17 @@ public function toAtom($queryParams, $fixedValues=null) { } - 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(); - } + private function loadLinkedItemsCount() { + Z_Core::debug("Loading linked items count for tag $this->id"); if (!$this->id) { - $this->linkedItemsLoaded = true; - return; + throw new Exception("id is required to fetch linked items count"); } - $sql = "SELECT `key` FROM itemTags JOIN items USING (itemID) WHERE tagID=?"; + $sql = "SELECT COUNT(*) FROM itemTags JOIN items USING (itemID) WHERE name=? AND libraryID=?"; $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; + $this->linkedItemsCount = Zotero_DB::columnQueryFromStatement($stmt, [$this->name, $this->libraryID]); } @@ -693,7 +218,9 @@ private function checkValue($field, $value) { if (!property_exists($this, $field)) { trigger_error("Invalid property '$field'", E_USER_ERROR); } - + if (!isset($value)) { + return; + } // Data validation switch ($field) { case 'id': @@ -703,20 +230,6 @@ private function checkValue($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); @@ -726,16 +239,6 @@ private function checkValue($field, $value) { } - 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); diff --git a/model/Tags.inc.php b/model/Tags.inc.php index 2539a2ad..85d5a5b6 100644 --- a/model/Tags.inc.php +++ b/model/Tags.inc.php @@ -24,88 +24,135 @@ ***** END LICENSE BLOCK ***** */ -class Zotero_Tags extends Zotero_ClassicDataObjects { +class Zotero_Tags { 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"); + public static function bulkDelete($libraryID, $itemID, $tags) { + if (sizeof($tags) == 0){ + return; } - - if (!$tagID) { - throw new Exception("Tag ID not provided"); + $placeholdersArray = array(); + $paramList = array(); + // Allow Zotero_Tag object and array of ints + foreach ($tags as $tag) { + if (gettype($tag) == 'object') { + $id = $tag->id; + } + else if (gettype($tag) == 'integer'){ + $id = $tag; + } + + if (!isset($id)) { + throw new Exception("Delete not possible for tag without a set tagID"); + } + $placeholdersArray[] = "?"; + $paramList = array_merge($paramList, [ + $id + ]); } - - if (isset(self::$tagsByID[$tagID])) { - return self::$tagsByID[$tagID]; + $placeholdersStr = implode(", ", $placeholdersArray); + + $updatedVersion = Zotero_Libraries::getUpdatedVersion($libraryID); + if (!isset($itemID)) { + $sql = "UPDATE items JOIN itemTags USING (itemID) SET items.version=? WHERE tagID in ($placeholdersStr)"; + $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($libraryID)); + $params = array_merge([$updatedVersion], $paramList); + Zotero_DB::queryFromStatement($stmt, $params); } - - if (!$skipCheck) { - $sql = 'SELECT COUNT(*) FROM tags WHERE tagID=?'; - $result = Zotero_DB::valueQuery($sql, $tagID, Zotero_Shards::getByLibraryID($libraryID)); - if (!$result) { - return false; - } + + $sql = "DELETE FROM itemTags WHERE tagID in ($placeholdersStr)"; + if (isset($itemID)) { + $sql .= " AND itemID=?"; + $paramList = array_merge($paramList, [$itemID]); } - - $tag = new Zotero_Tag; - $tag->libraryID = $libraryID; - $tag->id = $tagID; - - self::$tagsByID[$tagID] = $tag; - return self::$tagsByID[$tagID]; + $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($libraryID)); + Zotero_DB::queryFromStatement($stmt, $paramList); + + return $tags; } - - - /* - * Returns tagID for this tag - */ - public static function getID($libraryID, $name, $type, $caseInsensitive=false) { - if (!$libraryID) { - throw new Exception("Library ID not provided"); + + + public static function bulkInsert($libraryID, $itemID, $tags) { + if (sizeof($tags) == 0){ + return; } - - $name = trim($name); - $type = (int) $type; - - // TODO: cache - - $sql = "SELECT tagID FROM tags WHERE "; - if ($caseInsensitive) { - $sql .= "LOWER(name)=?"; - $params = [strtolower($name)]; + $placeholdersArray = array(); + $paramList = array(); + + // Get array of all names, and check if tags with those names already exist in the DB. + // If yes - we use existing tag's ID and (most importantly) version. + // If no - new ID and version = 0. + $placeholders = implode(',', array_fill(0, sizeOf($tags), '?')); + $names = array_map(function($tag) { + return $tag->name; + }, $tags); + $existingTagsSql = "SELECT t.tagID, t.name, MAX(t.version) as `version` from itemTags t JOIN items i USING (itemID) WHERE libraryID = ? AND name IN ($placeholders) GROUP BY tagID, `name`;"; + $existinTagData = Zotero_DB::query($existingTagsSql, array_merge([$libraryID], $names), Zotero_Shards::getByLibraryID($libraryID)); + + $existingTags = []; + foreach($existinTagData as $existingTag) { + $existingTags[$existingTag['name']] = $existingTag; } - else { - $sql .= "name=?"; - $params = [$name]; + foreach ($tags as $tag) { + if (isset($tag->id)) { + throw new Exception("Insert not possible for tag with a set tagID"); + } + $existingTag = array_key_exists($tag->name, $existingTags) ? $existingTags[$tag->name] : null; + $tag->id = $existingTag['tagID'] ? $existingTag['tagID'] : Zotero_ID::get('tags'); + $placeholdersArray[] = "(?, ?, ?, ?, ?)"; + $paramList = array_merge($paramList, [ + $tag->id, + $itemID, + $tag->name, + $tag->type, + $existingTag['version'] ? $existingTag['version'] : $tag->version, + ]); + } + + $placeholdersStr = implode(", ", $placeholdersArray); + $sql = "INSERT INTO itemTags (tagID, itemID, name, type, version) VALUES $placeholdersStr"; + + $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($libraryID)); + Zotero_DB::queryFromStatement($stmt, $paramList); + return $tags; + } + + public static function bulkGet($libraryID, $tagIDs) { + if (sizeof($tagIDs) == 0){ + return []; + } + $placeholders = implode(',', array_fill(0, sizeOf($tagIDs), '?')); + + $sql = "SELECT tagID, type, name, count(*) as count FROM itemTags WHERE tagID in ($placeholders) GROUP BY tagID, type, name"; + + $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($libraryID)); + $tags = Zotero_DB::queryFromStatement($stmt, $tagIDs); + $tagObjects = []; + foreach($tags as $tag) { + $tagObjects[] = new Zotero_Tag($tag['tagID'], $libraryID, $tag['name'], $tag['type'], null); } - $sql .= " AND type=? AND libraryID=?"; - array_push($params, $type, $libraryID); - $tagID = Zotero_DB::valueQuery($sql, $params, Zotero_Shards::getByLibraryID($libraryID)); - return $tagID; + return $tagObjects; + } + + public static function loadLinkedItemsKeys($libraryID, $tagName) { + $sql = "SELECT `key` FROM itemTags JOIN items USING (itemID) WHERE name=? AND libraryID=?"; + $stmt = Zotero_DB::getStatement($sql, true, $libraryID); + $itemKeys = Zotero_DB::columnQueryFromStatement($stmt, [$tagName, $libraryID]); + return $itemKeys ? $itemKeys : []; } - + // Temp function to make Deleted Controller not break due to ZoteroTags not being classic object + public static function getDeleteLogKeys($libraryID, $since, $bool) { + return []; + } + /* * Returns array of all tagIDs for this tag (of all types) */ @@ -113,7 +160,7 @@ public static function getIDs($libraryID, $name, $caseInsensitive=false) { // Default empty library if ($libraryID === 0) return []; - $sql = "SELECT tagID FROM tags WHERE libraryID=? AND name"; + $sql = "SELECT DISTINCT tagID FROM itemTags JOIN items USING (itemID) WHERE libraryID = ? AND name"; if ($caseInsensitive) { $sql .= " COLLATE utf8mb4_unicode_ci "; } @@ -136,8 +183,8 @@ public static function search($libraryID, $params) { $shardID = Zotero_Shards::getByLibraryID($libraryID); - $sql = "SELECT SQL_CALC_FOUND_ROWS DISTINCT tagID FROM tags " - . "JOIN itemTags USING (tagID) WHERE libraryID=? "; + $sql = "SELECT SQL_CALC_FOUND_ROWS DISTINCT tagID FROM itemTags " + . "JOIN items USING (itemID) WHERE libraryID=? "; $sqlParams = array($libraryID); // Pass a list of tagIDs, for when the initial search is done via SQL @@ -217,7 +264,7 @@ public static function search($libraryID, $params) { } if (!empty($params['since'])) { - $sql .= "AND version > ? "; + $sql .= "AND itemTags.version > ? "; $sqlParams[] = $params['since']; } @@ -248,10 +295,7 @@ public static function search($libraryID, $params) { $results['total'] = Zotero_DB::valueQuery("SELECT FOUND_ROWS()", false, $shardID); if ($ids) { - $tags = array(); - foreach ($ids as $id) { - $tags[] = Zotero_Tags::get($libraryID, $id); - } + $tags = Zotero_Tags::bulkGet($libraryID, $ids); $results['results'] = $tags; } diff --git a/model/old_Cite.inc.php b/model/old_Cite.inc.php new file mode 100644 index 00000000..69ca0546 --- /dev/null +++ b/model/old_Cite.inc.php @@ -0,0 +1,656 @@ +. + + ***** END LICENSE BLOCK ***** +*/ + +class Zotero_Cite { + private static $citePaperJournalArticleURL = false; + + + public static function getCitationFromCiteServer($item, array $queryParams) { + $json = self::getJSONFromItems(array($item)); + $response = self::makeRequest($queryParams, 'citation', $json); + $response = self::processCitationResponse($response); + if ($response) { + $key = self::getCacheKey('citation', $item, $queryParams); + Z_Core::$MC->set($key, $response, 3600); + } + return $response; + } + + + public static function getBibliographyFromCitationServer($items, array $queryParams) { + // Check cache first + $key = self::getBibCacheKey($items, $queryParams); + $cachedResponse = Z_Core::$MC->get($key); + if ($cachedResponse) { + return $cachedResponse; + } + + // Otherwise get from citeserver and cache + $json = self::getJSONFromItems($items); + $response = self::makeRequest($queryParams, 'bibliography', $json); + $response = self::processBibliographyResponse($response); + if ($response) { + Z_Core::$MC->set($key, $response, 900); + } + return $response; + } + + + public static function multiGetFromMemcached($mode, $items, array $queryParams) { + $keys = array(); + foreach ($items as $item) { + $keys[] = self::getCacheKey($mode, $item, $queryParams); + } + $results = Z_Core::$MC->get($keys); + + $response = array(); + if ($results) { + foreach ($results as $key => $val) { + $lk = self::extractLibraryKeyFromCacheKey($key); + $response[$lk] = $val; + } + } + + $hits = sizeOf($results); + $misses = sizeOf($items) - $hits; + StatsD::updateStats("memcached.cite.$mode.hits", $hits); + StatsD::updateStats("memcached.cite.$mode.misses", $misses); + + return $response; + } + + + public static function multiGetFromCiteServer($mode, $sets, array $queryParams) { + require_once("../include/RollingCurl.inc.php"); + + $t = microtime(true); + + $setIDs = array(); + $data = array(); + + $requestCallback = function ($response, $info) use ($mode, &$setIDs, &$data) { + if ($info['http_code'] != 200) { + error_log("WARNING: HTTP {$info['http_code']} from citeserver $mode request: " . $response); + return; + } + + $response = json_decode($response); + if (!$response) { + error_log("WARNING: Invalid response from citeserver $mode request: " . $response); + return; + } + + $str = parse_url($info['url']); + parse_str($str['query']); + + if ($mode == 'citation') { + $data[$setIDs[$setID]] = Zotero_Cite::processCitationResponse($response); + } + else if ($mode == 'bib') { + $data[$setIDs[$setID]] = Zotero_Cite::processBibliographyResponse($response); + } + }; + + $origURLPath = self::buildURLPath($queryParams, $mode); + + $rc = new RollingCurl($requestCallback); + // Number of simultaneous requests + $rc->window_size = 20; + foreach ($sets as $key => $items) { + $json = self::getJSONFromItems($items); + + $server = "http://" + . Z_CONFIG::$CITATION_SERVERS[array_rand(Z_CONFIG::$CITATION_SERVERS)]; + + // Include array position in URL so that the callback can figure + // out what request this was + $url = $server . $origURLPath . "&setID=" . $key; + // TODO: support multiple items per set, if necessary + if (!($items instanceof Zotero_Item)) { + throw new Exception("items is not a Zotero_Item"); + } + $setIDs[$key] = $items->libraryID . "/" . $items->key; + + $request = new RollingCurlRequest($url); + $request->options = array( + CURLOPT_POST => 1, + CURLOPT_POSTFIELDS => $json, + CURLOPT_HTTPHEADER => array("Expect:"), + CURLOPT_CONNECTTIMEOUT => 1, + CURLOPT_TIMEOUT => 8, + CURLOPT_HEADER => 0, // do not return HTTP headers + CURLOPT_RETURNTRANSFER => 1 + ); + $rc->add($request); + } + $rc->execute(); + + //error_log(sizeOf($sets) . " $mode requests in " . round(microtime(true) - $t, 3)); + + return $data; + } + + + // + // Ported from cite.js in the Zotero client + // + + /** + * Mappings for names + * Note that this is the reverse of the text variable map, since all mappings should be one to one + * and it makes the code cleaner + */ + private static $zoteroNameMap = array( + "author" => "author", + "editor" => "editor", + "bookAuthor" => "container-author", + "composer" => "composer", + "interviewer" => "interviewer", + "recipient" => "recipient", + "seriesEditor" => "collection-editor", + "translator" => "translator" + ); + + /** + * Mappings for text variables + */ + private static $zoteroFieldMap = array( + "title" => array("title"), + "container-title" => array("publicationTitle", "reporter", "code"), /* reporter and code should move to SQL mapping tables */ + "collection-title" => array("seriesTitle", "series"), + "collection-number" => array("seriesNumber"), + "publisher" => array("publisher", "distributor"), /* distributor should move to SQL mapping tables */ + "publisher-place" => array("place"), + "authority" => array("court"), + "page" => array("pages"), + "volume" => array("volume"), + "issue" => array("issue"), + "number-of-volumes" => array("numberOfVolumes"), + "number-of-pages" => array("numPages"), + "edition" => array("edition"), + "version" => array("versionNumber"), + "section" => array("section"), + "genre" => array("type", "artworkSize"), /* artworkSize should move to SQL mapping tables, or added as a CSL variable */ + "medium" => array("medium", "system"), + "archive" => array("archive"), + "archive_location" => array("archiveLocation"), + "event" => array("meetingName", "conferenceName"), /* these should be mapped to the same base field in SQL mapping tables */ + "event-place" => array("place"), + "abstract" => array("abstractNote"), + "URL" => array("url"), + "DOI" => array("DOI"), + "ISBN" => array("ISBN"), + "call-number" => array("callNumber"), + "note" => array("extra"), + "number" => array("number"), + "references" => array("history"), + "shortTitle" => array("shortTitle"), + "journalAbbreviation" => array("journalAbbreviation"), + "language" => array("language") + ); + + private static $zoteroDateMap = array( + "issued" => "date", + "accessed" => "accessDate" + ); + + private static $zoteroTypeMap = array( + 'book' => "book", + 'bookSection' => "chapter", + 'journalArticle' => "article-journal", + 'magazineArticle' => "article-magazine", + 'newspaperArticle' => "article-newspaper", + 'thesis' => "thesis", + 'encyclopediaArticle' => "entry-encyclopedia", + 'dictionaryEntry' => "entry-dictionary", + 'conferencePaper' => "paper-conference", + 'letter' => "personal_communication", + 'manuscript' => "manuscript", + 'interview' => "interview", + 'film' => "motion_picture", + 'artwork' => "graphic", + 'webpage' => "webpage", + 'report' => "report", + 'bill' => "bill", + 'case' => "legal_case", + 'hearing' => "bill", // ?? + 'patent' => "patent", + 'statute' => "bill", // ?? + 'email' => "personal_communication", + 'map' => "map", + 'blogPost' => "post-weblog", + 'instantMessage' => "personal_communication", + 'forumPost' => "post", + 'audioRecording' => "song", // ?? + 'presentation' => "speech", + 'videoRecording' => "motion_picture", + 'tvBroadcast' => "broadcast", + 'radioBroadcast' => "broadcast", + 'podcast' => "song", // ?? + 'computerProgram' => "book" // ?? + ); + + private static $quotedRegexp = '/^".+"$/'; + + public static function retrieveItem($zoteroItem) { + if (!$zoteroItem) { + throw new Exception("Zotero item not provided"); + } + + // don't return URL or accessed information for journal articles if a + // pages field exists + $itemType = Zotero_ItemTypes::getName($zoteroItem->itemTypeID); + $cslType = isset(self::$zoteroTypeMap[$itemType]) ? self::$zoteroTypeMap[$itemType] : false; + if (!$cslType) $cslType = "article"; + $ignoreURL = (($zoteroItem->getField("accessDate", true, true, true) || $zoteroItem->getField("url", true, true, true)) && + in_array($itemType, array("journalArticle", "newspaperArticle", "magazineArticle")) + && $zoteroItem->getField("pages", false, false, true) + && self::$citePaperJournalArticleURL); + + $cslItem = array( + 'id' => $zoteroItem->libraryID . "/" . $zoteroItem->key, + 'type' => $cslType + ); + + // get all text variables (there must be a better way) + // TODO: does citeproc-js permit short forms? + foreach (self::$zoteroFieldMap as $variable=>$fields) { + if ($variable == "URL" && $ignoreURL) continue; + + foreach($fields as $field) { + $value = $zoteroItem->getField($field, false, true, true); + if ($value !== "") { + // Strip enclosing quotes + if (preg_match(self::$quotedRegexp, $value)) { + $value = substr($value, 1, strlen($value)-2); + } + $cslItem[$variable] = $value; + break; + } + } + } + + // separate name variables + $authorID = Zotero_CreatorTypes::getPrimaryIDForType($zoteroItem->itemTypeID); + $creators = $zoteroItem->getCreators(); + foreach ($creators as $creator) { + if ($creator['creatorTypeID'] == $authorID) { + $creatorType = "author"; + } + else { + $creatorType = Zotero_CreatorTypes::getName($creator['creatorTypeID']); + } + + $creatorType = isset(self::$zoteroNameMap[$creatorType]) ? self::$zoteroNameMap[$creatorType] : false; + if (!$creatorType) continue; + + $nameObj = array('family' => $creator['ref']->lastName, 'given' => $creator['ref']->firstName); + + if (isset($cslItem[$creatorType])) { + $cslItem[$creatorType][] = $nameObj; + } + else { + $cslItem[$creatorType] = array($nameObj); + } + } + + // get date variables + foreach (self::$zoteroDateMap as $key=>$val) { + $date = $zoteroItem->getField($val, false, true, true); + if ($date) { + /*if (Zotero_Date::isSQLDateTime($date)) { + $date = substr($date, 0, 10); + } + $cslItem[$key] = ["raw" => $date];*/ + + $dateObj = Zotero_Date::strToDate($date); + $dateParts = []; + if (isset($dateObj['year'])) { + // add year, month, and day, if they exist + $dateParts[] = $dateObj['year']; + if (isset($dateObj['month']) && is_integer($dateObj['month'])) { + // Note: As of Zotero 5.0.30, the client's strToDate() returns a JS-style + // 0-indexed month. The dataserver version doesn't do that, so we don't + // add one to this. + $dateParts[] = $dateObj['month']; + if (!empty($dateObj['day'])) { + $dateParts[] = $dateObj['day']; + } + } + $cslItem[$key] = ["date-parts" => [$dateParts]]; + + // if no month, use season as month + if (!empty($dateObj['part']) + && (!isset($dateObj['month']) || !is_integer($dateObj['month']))) { + $cslItem[$key]['season'] = $dateObj['part']; + } + } + else { + // if no year, pass date literally + $cslItem[$key] = ["literal" => $date]; + } + } + } + + return $cslItem; + } + + + public static function getJSONFromItems($items, $asArray=false) { + // Allow a single item to be passed + if ($items instanceof Zotero_Item) { + $items = array($items); + } + + $cslItems = array(); + foreach ($items as $item) { + $cslItems[] = $item->toCSLItem(); + } + + $json = array( + "items" => $cslItems + ); + + if ($asArray) { + return $json; + } + + return json_encode($json); + } + + + private static function getCacheKey($mode, $item, array $queryParams) { + // Increment on code changes + $version = 1; + + $lk = $item->libraryID . "/" . $item->key; + + // Any query parameters that have an effect on the output + // need to be added here + $allowedParams = [ + 'style', + 'locale', + 'css', + 'linkwrap' + ]; + $cachedParams = Z_Array::filterKeys($queryParams, $allowedParams); + + return $mode . "_" . $lk . "_" + . md5($item->etag . json_encode($cachedParams)) + . "_$version" + . (isset(Z_CONFIG::$CACHE_VERSION_BIB) + ? "_" . Z_CONFIG::$CACHE_VERSION_BIB + : ""); + } + + + private static function getBibCacheKey(array $items, array $queryParams) { + // Any query parameters that have an effect on the output + // need to be added here + $allowedParams = array( + 'style', + 'locale', + 'css', + 'linkwrap' + ); + $cachedParams = Z_Array::filterKeys($queryParams, $allowedParams); + + $itemStr = implode('_', array_map(function ($item) { + return $item->id . '/' . $item->version; + }, $items)); + + return "bib_" + . md5($itemStr . json_encode($cachedParams)) + . (isset(Z_CONFIG::$CACHE_VERSION_BIB) + ? "_" . Z_CONFIG::$CACHE_VERSION_BIB + : ""); + } + + + private static function extractLibraryKeyFromCacheKey($cacheKey) { + preg_match('"[^_]+_([^_]+)_"', $cacheKey, $matches); + return $matches[1]; + } + + + private static function buildURLPath(array $queryParams, $mode) { + $url = "/?responseformat=json"; + foreach ($queryParams as $param => $value) { + switch ($param) { + case 'style': + if (!is_string($value) || !preg_match('/^(https?|[a-zA-Z0-9\-]+$)/', $value)) { + throw new Exception("Invalid style", Z_ERROR_CITESERVER_INVALID_STYLE); + } + $url .= "&" . $param . "=" . urlencode($value); + break; + + case 'linkwrap': + $url .= "&" . $param . "=" . ($value ? "1" : "0"); + break; + } + } + if ($mode == 'citation') { + $url .= "&citations=1&bibliography=0"; + } + if ($queryParams['locale'] != "en-US" + && preg_match('/^[a-z]{2}(-[A-Z]{2})?/', $queryParams['locale'], $matches)) { + if (strlen($matches[0]) == 2) { + $matches[0] = $matches . '-' . strtoupper($matches[0]); + } + $url .= "&locale=" . $matches[0]; + } + return $url; + } + + + private static function makeRequest(array $queryParams, $mode, $json) { + $servers = Z_CONFIG::$CITATION_SERVERS; + // Try servers in a random order + shuffle($servers); + + foreach ($servers as $server) { + $url = "http://" . $server . self::buildURLPath($queryParams, $mode); + + $start = microtime(true); + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, 1); + //error_log("curl -d " . escapeshellarg($json) . " " . escapeshellarg($url)); + curl_setopt($ch, CURLOPT_POSTFIELDS, $json); + curl_setopt($ch, CURLOPT_HTTPHEADER, array("Expect:")); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1); + curl_setopt($ch, CURLOPT_TIMEOUT, 4); + curl_setopt($ch, CURLOPT_HEADER, 0); // do not return HTTP headers + curl_setopt($ch, CURLOPT_RETURNTRANSFER , 1); + $response = curl_exec($ch); + + $time = microtime(true) - $start; + //error_log("Bib request took " . round($time, 3)); + StatsD::timing("api.cite.$mode", $time * 1000); + + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if ($code == 400) { + throw new Exception("Invalid style", Z_ERROR_CITESERVER_INVALID_STYLE); + } + + if ($code == 404) { + throw new Exception("Style not found", Z_ERROR_CITESERVER_INVALID_STYLE); + } + + if ($code != 200) { + error_log($code . " from citation server -- trying another " + . "[URL: '$url'] [INPUT: '$json'] [RESPONSE: '$response']"); + } + + // If no response, try another server + if (!$response) { + continue; + } + + break; + } + + if (!$response) { + throw new Exception("Error generating $mode"); + } + + $response = json_decode($response); + if (!$response) { + throw new Exception("Error generating $mode -- invalid response"); + } + + return $response; + } + + + public static function processCitationResponse($response) { + if (strpos($response->citations[0][1], "[CSL STYLE ERROR: ") !== false) { + return false; + } + return "" . $response->citations[0][1] . ""; + } + + + public static function processBibliographyResponse($response, $css='inline') { + // + // Ported from Zotero.Cite.makeFormattedBibliography() in Zotero client + // + $bib = $response->bibliography; + $html = $bib[0]->bibstart . implode("", $bib[1]) . $bib[0]->bibend; + + if ($css == "none") { + return $html; + } + + $sfa = "second-field-align"; + + //if (!empty($_GET['citedebug'])) { + // echo "\n\n"; + //} + + // Validate input + if (!is_numeric($bib[0]->maxoffset)) throw new Exception("Invalid maxoffset"); + if (!is_numeric($bib[0]->entryspacing)) throw new Exception("Invalid entryspacing"); + if (!is_numeric($bib[0]->linespacing)) throw new Exception("Invalid linespacing"); + + $maxOffset = (int) $bib[0]->maxoffset; + $entrySpacing = (int) $bib[0]->entryspacing; + $lineSpacing = (int) $bib[0]->linespacing; + $hangingIndent = !empty($bib[0]->hangingindent) ? (int) $bib[0]->hangingindent : 0; + $secondFieldAlign = !empty($bib[0]->$sfa); // 'flush' and 'margin' are the same for HTML + + $xml = new SimpleXMLElement($html); + + $multiField = !!$xml->xpath("//div[@class = 'csl-left-margin']"); + + // One of the characters is usually a period, so we can adjust this down a bit + $maxOffset = max(1, $maxOffset - 2); + + // Force a minimum line height + if ($lineSpacing <= 1.35) $lineSpacing = 1.35; + + $xml['style'] .= "line-height: " . $lineSpacing . "; "; + + if ($hangingIndent) { + if ($multiField && !$secondFieldAlign) { + throw new Exception("second-field-align=false and hangingindent=true combination is not currently supported"); + } + // If only one field, apply hanging indent on root + else if (!$multiField) { + $xml['style'] .= "padding-left: {$hangingIndent}em; text-indent:-{$hangingIndent}em;"; + } + } + + $leftMarginDivs = $xml->xpath("//div[@class = 'csl-left-margin']"); + $clearEntries = sizeOf($leftMarginDivs) > 0; + + // csl-entry + $divs = $xml->xpath("//div[@class = 'csl-entry']"); + $num = sizeOf($divs); + $i = 0; + foreach ($divs as $div) { + $first = $i == 0; + $last = $i == $num - 1; + + if ($clearEntries) { + $div['style'] .= "clear: left; "; + } + + if ($entrySpacing) { + if (!$last) { + $div['style'] .= "margin-bottom: " . $entrySpacing . "em;"; + } + } + + $i++; + } + + // Padding on the label column, which we need to include when + // calculating offset of right column + $rightPadding = .5; + + // div.csl-left-margin + foreach ($leftMarginDivs as $div) { + $div['style'] = "float: left; padding-right: " . $rightPadding . "em; "; + + // Right-align the labels if aligning second line, since it looks + // better and we don't need the second line of text to align with + // the left edge of the label + if ($secondFieldAlign) { + $div['style'] .= "text-align: right; width: " . $maxOffset . "em;"; + } + } + + // div.csl-right-inline + foreach ($xml->xpath("//div[@class = 'csl-right-inline']") as $div) { + $div['style'] .= "margin: 0 .4em 0 " . ($secondFieldAlign ? $maxOffset + $rightPadding : "0") . "em;"; + + if ($hangingIndent) { + $div['style'] .= "padding-left: {$hangingIndent}em; text-indent:-{$hangingIndent}em;"; + } + } + + // div.csl-indent + foreach ($xml->xpath("//div[@class = 'csl-indent']") as $div) { + $div['style'] = "margin: .5em 0 0 2em; padding: 0 0 .2em .5em; border-left: 5px solid #ccc;"; + } + + return $xml->asXML(); + } + + + /*Zotero.Cite.System.getAbbreviations = function() { + return {}; + }*/ +} +?> \ No newline at end of file diff --git a/model/old_Collection.inc.php b/model/old_Collection.inc.php new file mode 100644 index 00000000..b2ca9c07 --- /dev/null +++ b/model/old_Collection.inc.php @@ -0,0 +1,910 @@ +. + + ***** END LICENSE BLOCK ***** +*/ + +class Zotero_Collection extends Zotero_DataObject { + protected $objectType = 'collection'; + protected $dataTypesExtended = ['childCollections', 'childItems', 'relations']; + + protected $_name; + protected $_dateAdded; + protected $_dateModified; + + private $_hasChildCollections; + private $childCollections = []; + + private $_hasChildItems; + private $childItems = []; + + + public function __get($field) { + switch ($field) { + case 'etag': + return $this->getETag(); + + default: + return parent::__get($field); + } + } + + + /** + * Check if collection 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 collections WHERE collectionID=?"; + return !!Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); + } + + + public function save($userID=false) { + if (!$this->_libraryID) { + trigger_error("Library ID must be set before saving", E_USER_ERROR); + } + + Zotero_Collections::editCheck($this, $userID); + + if (!$this->hasChanged()) { + Z_Core::debug("Collection $this->_id has not changed"); + return false; + } + + $env = []; + $isNew = $env['isNew'] = !$this->_id; + + Zotero_DB::beginTransaction(); + + try { + $collectionID = $env['id'] = $this->_id = $this->_id ? $this->_id : Zotero_ID::get('collections'); + + Z_Core::debug("Saving collection $this->_id"); + + $key = $env['key'] = $this->_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 = Zotero_Libraries::getUpdatedVersion($this->_libraryID); + + // Verify parent + if ($this->_parentKey) { + $newParentCollection = Zotero_Collections::getByLibraryAndKey( + $this->_libraryID, $this->_parentKey + ); + + if (!$newParentCollection) { + // TODO: clear caches + throw new Exception( + "Parent collection $this->_libraryID/$this->_parentKey doesn't exist", + Z_ERROR_COLLECTION_NOT_FOUND + ); + } + + if (!$isNew) { + if ($newParentCollection->id == $collectionID) { + trigger_error("Cannot move collection $this->_id into itself!", E_USER_ERROR); + } + + // If the designated parent collection is already within this + // collection (which shouldn't happen), move it to the root + if (!$isNew && $this->hasDescendent('collection', $newParentCollection->id)) { + $newParentCollection->parentKey = null; + $newParentCollection->save(); + } + } + + $parent = $newParentCollection->id; + } + else { + $parent = null; + } + + $fields = "collectionName=?, parentCollectionID=?, libraryID=?, `key`=?, + dateAdded=?, dateModified=?, serverDateModified=?, version=?"; + $params = array( + $this->_name, + $parent, + $this->_libraryID, + $key, + $dateAdded, + $dateModified, + $timestamp, + $version + ); + + $params = array_merge(array($collectionID), $params, $params); + $shardID = Zotero_Shards::getByLibraryID($this->_libraryID); + + $sql = "INSERT INTO collections SET collectionID=?, $fields + ON DUPLICATE KEY UPDATE $fields"; + Zotero_DB::query($sql, $params, $shardID); + + // Remove from delete log if it's there + $sql = "DELETE FROM syncDeleteLogKeys WHERE libraryID=? AND objectType='collection' AND `key`=?"; + Zotero_DB::query($sql, array($this->_libraryID, $key), $shardID); + + Zotero_DB::commit(); + + if (!empty($this->changed['parentKey'])) { + $objectsClass = $this->objectsClass; + + // Add this to the parent's cached collection lists after commit, + // if the parent was loaded + if ($this->_parentKey) { + $parentCollectionID = $objectsClass::getIDFromLibraryAndKey( + $this->_libraryID, $this->_parentKey + ); + $objectsClass::registerChildCollection($parentCollectionID, $collectionID); + } + // Remove this from the previous parent's cached collection lists + // if the parent was loaded + else if (!$isNew && !empty($this->previousData['parentKey'])) { + $parentCollectionID = $objectsClass::getIDFromLibraryAndKey( + $this->_libraryID, $this->previousData['parentKey'] + ); + $objectsClass::unregisterChildCollection($parentCollectionID, $collectionID); + } + } + + // Related items + if (!empty($this->changed['relations'])) { + $removed = []; + $new = []; + $current = $this->relations; + + 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::getCollectionURI($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); + } + } + + 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 (isset($this->changed['deleted'])) { + if ($this->_deleted) { + $sql = "REPLACE INTO deletedCollections (collectionID) VALUES (?)"; + } + else { + $sql = "DELETE FROM deletedCollections WHERE collectionID=?"; + } + Zotero_DB::query($sql, $collectionID, $shardID); + } + } + catch (Exception $e) { + Zotero_DB::rollback(); + throw ($e); + } + + $this->finalizeSave($env); + + return $isNew ? $this->_id : true; + } + + + /** + * Update the collection's version without changing any data + */ + public function updateVersion($userID) { + $this->changed['primaryData'] = true; + $this->save($userID); + } + + + /** + * Returns child collections + * + * @return {Integer[]} Array of collectionIDs + */ + public function getChildCollections() { + $this->loadChildCollections(); + return $this->childCollections; + } + + + /* + public function setChildCollections($collectionIDs) { + Zotero_DB::beginTransaction(); + + if (!$this->childCollectionsLoaded) { + $this->loadChildCollections(); + } + + $current = $this->childCollections; + $removed = array_diff($current, $collectionIDs); + $new = array_diff($collectionIDs, $current); + + if ($removed) { + $sql = "UPDATE collections SET parentCollectionID=NULL + WHERE userID=? AND collectionID IN ("; + $q = array(); + $params = array($this->userID, $this->id); + foreach ($removed as $collectionID) { + $q[] = '?'; + $params[] = $collectionID; + } + $sql .= implode(',', $q) . ")"; + Zotero_DB::query($sql, $params); + } + + if ($new) { + $sql = "UPDATE collections SET parentCollectionID=? + WHERE userID=? AND collectionID IN ("; + $q = array(); + $params = array($this->userID); + foreach ($new as $collectionID) { + $q[] = '?'; + $params[] = $collectionID; + } + $sql .= implode(',', $q) . ")"; + Zotero_DB::query($sql, $params); + } + + $this->childCollections = $new; + + Zotero_DB::commit(); + } + */ + + + public function numCollections() { + if ($this->loaded['childCollections']) { + return sizeOf($this->childCollections); + } + $sql = "SELECT COUNT(*) FROM collections WHERE parentCollectionID=?"; + $num = Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); + return $num; + } + + + public function numItems($includeDeleted=false) { + $sql = "SELECT COUNT(*) FROM collectionItems "; + if (!$includeDeleted) { + $sql .= "LEFT JOIN deletedItems DI USING (itemID)"; + } + $sql .= "WHERE collectionID=?"; + if (!$includeDeleted) { + $sql .= " AND DI.itemID IS NULL"; + } + return Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); + } + + + /** + * Returns child items + * + * @return {Integer[]} Array of itemIDs + */ + public function getItems($includeChildItems=false) { + $this->loadChildItems(); + + if ($includeChildItems) { + $sql = "(SELECT INo.itemID FROM itemNotes INo " + . "JOIN items I ON (INo.sourceItemID=I.itemID) " + . "JOIN collectionItems CI ON (I.itemID=CI.itemID) " + . "WHERE collectionID=?)" + . " UNION " + . "(SELECT IA.itemID FROM itemAttachments IA " + . "JOIN items I ON (IA.sourceItemID=I.itemID) " + . "JOIN collectionItems CI ON (I.itemID=CI.itemID) " + . "WHERE collectionID=?)"; + $childItemIDs = Zotero_DB::columnQuery( + $sql, array($this->id, $this->id), Zotero_Shards::getByLibraryID($this->libraryID) + ); + if ($childItemIDs) { + return array_merge($this->childItems, $childItemIDs); + } + } + + return $this->childItems; + } + + + public function setItems($itemIDs) { + $shardID = Zotero_Shards::getByLibraryID($this->libraryID); + + Zotero_DB::beginTransaction(); + + $this->loadChildItems(); + + $current = $this->childItems; + $removed = array_diff($current, $itemIDs); + $new = array_diff($itemIDs, $current); + + if ($removed) { + $arr = $removed; + $sql = "DELETE FROM collectionItems WHERE collectionID=? AND itemID IN ("; + while ($chunk = array_splice($arr, 0, 500)) { + array_unshift($chunk, $this->id); + Zotero_DB::query( + $sql . implode(', ', array_fill(0, sizeOf($chunk) - 1, '?')) . ")", + $chunk, + $shardID + ); + } + } + + if ($new) { + $arr = $new; + $sql = "INSERT INTO collectionItems (collectionID, itemID) VALUES "; + while ($chunk = array_splice($arr, 0, 250)) { + Zotero_DB::query( + $sql . implode(',', array_fill(0, sizeOf($chunk), '(?,?)')), + call_user_func_array( + 'array_merge', + array_map(function ($itemID) { + return [$this->id, $itemID]; + }, $chunk) + ), + $shardID + ); + } + } + + $this->childItems = array_values(array_unique($itemIDs)); + + Zotero_DB::commit(); + } + + + /** + * Add an item to the collection. The item's version must be updated + * separately. + */ + public function addItem($itemID) { + if ($this->hasItem($itemID)) { + Z_Core::debug("Item $itemID is already a child of collection $this->id"); + return; + } + + $this->setItems(array_merge($this->getItems(), array($itemID))); + } + + + /** + * Add items to the collection. The items' versions must be updated + * separately. + */ + public function addItems($itemIDs) { + $items = array_merge($this->getItems(), $itemIDs); + $this->setItems($items); + } + + + /** + * Remove an item from the collection. The item's version must be updated + * separately. + */ + public function removeItem($itemID) { + if (!$this->hasItem($itemID)) { + Z_Core::debug("Item $itemID is not a child of collection $this->id"); + return false; + } + + $items = $this->getItems(); + array_splice($items, array_search($itemID, $items), 1); + $this->setItems($items); + + return true; + } + + + + /** + * Check if an item belongs to the collection + */ + public function hasItem($itemID) { + $this->loadChildItems(); + return in_array($itemID, $this->childItems); + } + + + public function hasDescendent($type, $id) { + $descendents = $this->getChildren(true, false, $type); + for ($i=0, $len=sizeOf($descendents); $i<$len; $i++) { + if ($descendents[$i]['id'] == $id) { + return true; + } + } + return false; + } + + + /** + * Returns an array of descendent collections and items + * (rows of 'id', 'type' ('item' or 'collection'), 'parent', and, + * if collection, 'name' and the nesting 'level') + * + * @param bool $recursive Descend into subcollections + * @param bool $nested Return multidimensional array with 'children' + * nodes instead of flat array + * @param string $type 'item', 'collection', or FALSE for both + */ + public function getChildren($recursive=false, $nested=false, $type=false, $level=1) { + $toReturn = array(); + + // 0 == collection + // 1 == item + $children = Zotero_DB::query('SELECT collectionID AS id, + 0 AS type, collectionName AS collectionName, `key` + FROM collections WHERE parentCollectionID=? + UNION SELECT itemID AS id, 1 AS type, NULL AS collectionName, `key` + FROM collectionItems JOIN items USING (itemID) WHERE collectionID=?', + array($this->id, $this->id), + Zotero_Shards::getByLibraryID($this->libraryID) + ); + + if ($type) { + switch ($type) { + case 'item': + case 'collection': + break; + default: + throw ("Invalid type '$type'"); + } + } + + for ($i=0, $len=sizeOf($children); $i<$len; $i++) { + // This seems to not work without parseInt() even though + // typeof children[i]['type'] == 'number' and + // children[i]['type'] === parseInt(children[i]['type']), + // which sure seems like a bug to me + switch ($children[$i]['type']) { + case 0: + if (!$type || $type == 'collection') { + $toReturn[] = array( + 'id' => $children[$i]['id'], + 'name' => $children[$i]['collectionName'], + 'key' => $children[$i]['key'], + 'type' => 'collection', + 'level' => $level, + 'parent' => $this->id + ); + } + + if ($recursive) { + $col = Zotero_Collections::getByLibraryAndKey($this->libraryID, $children[$i]['key']); + $descendents = $col->getChildren(true, $nested, $type, $level+1); + + if ($nested) { + $toReturn[sizeOf($toReturn) - 1]['children'] = $descendents; + } + else { + for ($j=0, $len2=sizeOf($descendents); $j<$len2; $j++) { + $toReturn[] = $descendents[$j]; + } + } + } + break; + + case 1: + if (!$type || $type == 'item') { + $toReturn[] = array( + 'id' => $children[$i]['id'], + 'key' => $children[$i]['key'], + 'type' => 'item', + 'parent' => $this->id + ); + } + break; + } + } + + return $toReturn; + } + + + // + // Methods dealing with relations + // + // save() is not required for relations functions + // + /** + * Returns all relations of the collection + * + * @return object Object with predicates as keys and URIs as values + */ + public function getRelations() { + if (!$this->_id) { + return array(); + } + $relations = Zotero_Relations::getByURIs( + $this->libraryID, + Zotero_URI::getCollectionURI($this) + ); + + $toReturn = new stdClass; + foreach ($relations as $relation) { + $toReturn->{$relation->predicate} = $relation->object; + } + return $toReturn; + } + + + /** + * Returns all tags assigned to items in this collection + */ + public function getTags($asIDs=false) { + $sql = "SELECT tagID FROM tags JOIN itemTags USING (tagID) + JOIN collectionItems USING (itemID) WHERE collectionID=? ORDER BY name"; + $tagIDs = Zotero_DB::columnQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); + if (!$tagIDs) { + return false; + } + + if ($asIDs) { + return $tagIDs; + } + + $tagObjs = array(); + foreach ($tagIDs as $tagID) { + $tag = Zotero_Tags::get($tagID, true); + $tagObjs[] = $tag; + } + return $tagObjs; + } + + + /* + * Returns an array keyed by tagID with the number of linked items for each tag + * in this collection + */ + public function getTagItemCounts() { + $sql = "SELECT tagID, COUNT(*) AS numItems FROM tags JOIN itemTags USING (tagID) + JOIN collectionItems USING (itemID) WHERE collectionID=? GROUP BY tagID"; + $rows = Zotero_DB::query($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); + if (!$rows) { + return false; + } + + $counts = array(); + foreach ($rows as $row) { + $counts[$row['tagID']] = $row['numItems']; + } + return $counts; + } + + + public function toResponseJSON(array $requestParams) { + $t = microtime(true); + + $libraryData = Zotero_Libraries::toJSON($this->libraryID); + // Child collections and items can't be cached (easily) + $numCollections = $this->numCollections(); + $numItems = $this->numItems(); + + $cacheKey = $this->getCacheKey($requestParams); + $cached = Z_Core::$MC->get($cacheKey); + if ($cached) { + Z_Core::debug("Using cached JSON for collection $this->libraryKey"); + $cached['library'] = $libraryData; + $cached['meta']->numCollections = $numCollections; + $cached['meta']->numItems = $numItems; + + StatsD::timing("api.collections.toResponseJSON.cached", (microtime(true) - $t) * 1000); + StatsD::increment("memcached.collections.toResponseJSON.hit"); + return $cached; + } + + $json = [ + 'key' => $this->key, + 'version' => $this->version, + 'library' => new stdClass() + ]; + + // 'links' + $json['links'] = [ + 'self' => [ + 'href' => Zotero_API::getCollectionURI($this), + 'type' => 'application/json' + ], + 'alternate' => [ + 'href' => Zotero_URI::getCollectionURI($this, true), + 'type' => 'text/html' + ] + ]; + + $parentID = $this->getParentID(); + if ($parentID) { + $parentCol = Zotero_Collections::get($this->libraryID, $parentID); + $json['links']['up'] = [ + 'href' => Zotero_API::getCollectionURI($parentCol), + 'type' => "application/json" + ]; + } + + // 'meta' + $json['meta'] = new stdClass; + $json['meta']->numCollections = $numCollections; + $json['meta']->numItems = $numItems; + + // 'include' + $include = $requestParams['include']; + + foreach ($include as $type) { + if ($type == 'data') { + $json[$type] = $this->toJSON($requestParams); + } + } + + Z_Core::$MC->set($cacheKey, $json); + $json['library'] = $libraryData; + + StatsD::timing("api.collections.toResponseJSON.uncached", (microtime(true) - $t) * 1000); + StatsD::increment("memcached.collections.toResponseJSON.miss"); + + return $json; + } + + + public function toJSON(array $requestParams=[]) { + if (!$this->loaded) { + $this->load(); + } + + if ($requestParams['v'] >= 3) { + $arr['key'] = $this->key; + $arr['version'] = $this->version; + } + else { + $arr['collectionKey'] = $this->key; + $arr['collectionVersion'] = $this->version; + } + + $arr['name'] = $this->name; + $parentKey = $this->getParentKey(); + if ($requestParams['v'] >= 2) { + $arr['parentCollection'] = $parentKey ? $parentKey : false; + $arr['relations'] = $this->getRelations(); + if ($this->getDeleted()) { + $arr['deleted'] = true; + } + } + else { + $arr['parent'] = $parentKey ? $parentKey : false; + } + + return $arr; + } + + + protected function loadChildCollections($reload = false) { + if ($this->loaded['childCollections'] && !$reload) return; + + Z_Core::debug("Loading subcollections for collection $this->id"); + + if (!$this->id) { + trigger_error('$this->id not set', E_USER_ERROR); + } + + $sql = "SELECT collectionID FROM collections WHERE parentCollectionID=?"; + $ids = Zotero_DB::columnQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); + + $this->childCollections = $ids ? $ids : []; + $this->loaded['childCollections'] = true; + $this->clearChanged('childCollections'); + } + + + protected function loadChildItems($reload = false) { + if ($this->loaded['childItems'] && !$reload) return; + + Z_Core::debug("Loading child items for collection $this->id"); + + if (!$this->id) { + trigger_error('$this->id not set', E_USER_ERROR); + } + + $sql = "SELECT itemID FROM collectionItems WHERE collectionID=?"; + $ids = Zotero_DB::columnQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); + + $this->childItems = $ids ? $ids : []; + + $this->loaded['childItems'] = true; + $this->clearChanged('childItems'); + } + + + /** + * Add a collection to the cached child collections list if loaded + */ + public function registerChildCollection($collectionID) { + if ($this->loaded['childCollections']) { + $collection = Zotero_Collections::get($this->libraryID, $collectionID); + if ($collection) { + $this->_hasChildCollections = true; + $this->childCollections[] = $collection; + } + } + } + + + /** + * Remove a collection from the cached child collections list if loaded + */ + public function unregisterChildCollection($collectionID) { + if ($this->loaded['childCollections']) { + for ($i = 0; $i < sizeOf($this->childCollections); $i++) { + if ($this->childCollections[$i]->id == $collectionID) { + unset($this->childCollections[$i]); + break; + } + } + $this->_hasChildCollections = !!$this->childCollections; + } + } + + + /** + * Add an item to the cached child items list if loaded + */ + public function registerChildItem($itemID) { + if ($this->loaded['childItems']) { + $item = Zotero_Items::get($this->libraryID, $itemID); + if ($item) { + $this->_hasChildItems = true; + $this->childItems[] = $item; + } + } + } + + + /** + * Remove an item from the cached child items list if loaded + */ + public function unregisterChildItem($itemID) { + if ($this->loaded['childItems']) { + for ($i = 0; $i < sizeOf($this->childItems); $i++) { + if ($this->childItems[$i]->id == $itemID) { + unset($this->childItems[$i]); + break; + } + } + $this->_hasChildItems = !!$this->childItems; + } + } + + + protected function loadRelations($reload = false) { + if ($this->loaded['relations'] && !$reload) return; + + if (!$this->id) { + return; + } + + Z_Core::debug("Loading relations for collection $this->id"); + + if (!$this->loaded) { + $this->load(); + } + + $collectionURI = Zotero_URI::getCollectionURI($this); + + $relations = Zotero_Relations::getByURIs($this->libraryID, $collectionURI); + $relations = array_map(function ($rel) { + return [$rel->predicate, $rel->object]; + }, $relations); + + $this->relations = $relations; + $this->loaded['relations'] = true; + $this->clearChanged('relations'); + } + + + protected function checkValue($field, $value) { + parent::checkValue($field, $value); + + switch ($field) { + case 'name': + if (mb_strlen($value) > Zotero_Collections::$maxLength) { + throw new Exception("Collection '" . $value . "' too long", Z_ERROR_COLLECTION_TOO_LONG); + } + break; + } + } + + + private function getCacheKey($requestParams) { + $cacheVersion = 2; + $key = "collectionResponseJSON_" + . md5( + implode("_", [ + $this->libraryID, + $this->key, + $this->version, + implode(',', $requestParams['include']), + $requestParams['v'], + // For code-based changes + "_" . $cacheVersion, + // For data-based changes + (isset(Z_CONFIG::$CACHE_VERSION_RESPONSE_JSON_COLLECTION) + ? "_" . Z_CONFIG::$CACHE_VERSION_RESPONSE_JSON_COLLECTION + : ""), + ]) + ); + return $key; + } + + + private function getETag() { + if (!$this->loaded) { + $this->load(); + } + + return md5($this->name . "_" . $this->getParentID()); + } + + + 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_Creator.inc.php b/model/old_Creator.inc.php new file mode 100644 index 00000000..54d6b9a9 --- /dev/null +++ b/model/old_Creator.inc.php @@ -0,0 +1,385 @@ +. + + ***** END LICENSE BLOCK ***** +*/ + +class Zotero_Creator { + private $id; + private $libraryID; + private $key; + private $firstName = ''; + private $lastName = ''; + private $shortName = ''; + private $fieldMode = 0; + private $birthYear; + private $dateAdded; + private $dateModified; + + private $loaded = false; + private $changed = 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->changed = array(); + $props = array( + 'firstName', + 'lastName', + 'shortName', + 'fieldMode', + 'birthYear', + 'dateAdded', + 'dateModified' + ); + 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_Creator', $field)) { + throw new Exception("Zotero_Creator 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 creator is already loaded"); + } + $this->checkValue($field, $value); + $this->$field = $value; + return; + + case 'firstName': + case 'lastName': + $value = Zotero_Utilities::unicodeTrim($value); + break; + } + + if ($this->id || $this->key) { + if (!$this->loaded) { + $this->load(true); + } + } + else { + $this->loaded = true; + } + + $this->checkValue($field, $value); + + if ($this->$field !== $value) { + $this->changed[$field] = true; + $this->$field = $value; + } + } + + + /** + * Check if creator 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 creators WHERE creatorID=?"; + return !!Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); + } + + + public function hasChanged() { + return in_array(true, array_values($this->changed)); + } + + + public function save($userID=false) { + if (!$this->libraryID) { + trigger_error("Library ID must be set before saving", E_USER_ERROR); + } + + Zotero_Creators::editCheck($this, $userID); + + // If empty, move on + if ($this->firstName === '' && $this->lastName === '') { + throw new Exception('First and last name are empty'); + } + + if ($this->fieldMode == 1 && $this->firstName !== '') { + throw new Exception('First name must be empty in single-field mode'); + } + + if (!$this->hasChanged()) { + Z_Core::debug("Creator $this->id has not changed"); + return false; + } + + Zotero_DB::beginTransaction(); + + try { + $creatorID = $this->id ? $this->id : Zotero_ID::get('creators'); + $isNew = !$this->id; + + Z_Core::debug("Saving creator $this->id"); + + $key = $this->key ? $this->key : Zotero_ID::getKey(); + + $timestamp = Zotero_DB::getTransactionTimestamp(); + + $dateAdded = $this->dateAdded ? $this->dateAdded : $timestamp; + $dateModified = !empty($this->changed['dateModified']) ? $this->dateModified : $timestamp; + + $fields = "firstName=?, lastName=?, fieldMode=?, + libraryID=?, `key`=?, dateAdded=?, dateModified=?, serverDateModified=?"; + $params = array( + $this->firstName, + $this->lastName, + $this->fieldMode, + $this->libraryID, + $key, + $dateAdded, + $dateModified, + $timestamp + ); + $shardID = Zotero_Shards::getByLibraryID($this->libraryID); + + try { + if ($isNew) { + $sql = "INSERT INTO creators SET creatorID=?, $fields"; + $stmt = Zotero_DB::getStatement($sql, true, $shardID); + Zotero_DB::queryFromStatement($stmt, array_merge(array($creatorID), $params)); + + // Remove from delete log if it's there + $sql = "DELETE FROM syncDeleteLogKeys WHERE libraryID=? AND objectType='creator' AND `key`=?"; + Zotero_DB::query($sql, array($this->libraryID, $key), $shardID); + } + else { + $sql = "UPDATE creators SET $fields WHERE creatorID=?"; + $stmt = Zotero_DB::getStatement($sql, true, $shardID); + Zotero_DB::queryFromStatement($stmt, array_merge($params, array($creatorID))); + } + } + catch (Exception $e) { + if (strpos($e->getMessage(), " too long") !== false) { + if (strlen($this->firstName) > 255) { + $name = $this->firstName; + } + else if (strlen($this->lastName) > 255) { + $name = $this->lastName; + } + else { + throw $e; + } + $name = mb_substr($name, 0, 50); + throw new Exception( + "=Creator value '{$name}…' too long", + Z_ERROR_CREATOR_TOO_LONG + ); + } + + throw $e; + } + + // The client updates the mod time of associated items here, but + // we don't, because either A) this is from syncing, where appropriate + // mod times come from the client or B) the change is made through + // $item->setCreator(), which updates the mod time. + // + // If the server started to make other independent creator changes, + // linked items would need to be updated. + + Zotero_DB::commit(); + + Zotero_Creators::cachePrimaryData( + array( + 'id' => $creatorID, + 'libraryID' => $this->libraryID, + 'key' => $key, + 'dateAdded' => $dateAdded, + 'dateModified' => $dateModified, + 'firstName' => $this->firstName, + 'lastName' => $this->lastName, + 'fieldMode' => $this->fieldMode + ) + ); + } + catch (Exception $e) { + Zotero_DB::rollback(); + throw ($e); + } + + // If successful, set values in object + if (!$this->id) { + $this->id = $creatorID; + } + if (!$this->key) { + $this->key = $key; + } + + $this->init(); + + if ($isNew) { + Zotero_Creators::cache($this); + } + + // TODO: invalidate memcache? + + return $this->id; + } + + + public function getLinkedItems() { + if (!$this->id) { + return array(); + } + + $items = array(); + $sql = "SELECT itemID FROM itemCreators WHERE creatorID=?"; + $itemIDs = Zotero_DB::columnQuery( + $sql, + $this->id, + Zotero_Shards::getByLibraryID($this->libraryID) + ); + if (!$itemIDs) { + return $items; + } + foreach ($itemIDs as $itemID) { + $items[] = Zotero_Items::get($this->libraryID, $itemID); + } + return $items; + } + + + public function equals($creator) { + if (!$this->loaded) { + $this->load(); + } + + return + ($creator->firstName === $this->firstName) && + ($creator->lastName === $this->lastName) && + ($creator->fieldMode == $this->fieldMode); + } + + + private function load() { + if (!$this->libraryID) { + throw new Exception("Library ID not set"); + } + + if (!$this->id && !$this->key) { + throw new Exception("ID or key not set"); + } + + if ($this->id) { + //Z_Core::debug("Loading data for creator $this->libraryID/$this->id"); + $row = Zotero_Creators::getPrimaryDataByID($this->libraryID, $this->id); + } + else { + //Z_Core::debug("Loading data for creator $this->libraryID/$this->key"); + $row = Zotero_Creators::getPrimaryDataByKey($this->libraryID, $this->key); + } + + $this->loaded = true; + $this->changed = array(); + + if (!$row) { + return; + } + + if ($row['libraryID'] != $this->libraryID) { + throw new Exception("libraryID {$row['libraryID']} != $this->libraryID"); + } + + foreach ($row as $key=>$val) { + $this->$key = $val; + } + } + + + private function checkValue($field, $value) { + if (!property_exists($this, $field)) { + throw new Exception("Invalid property '$field'"); + } + + // Data validation + switch ($field) { + case 'id': + case 'libraryID': + if (!Zotero_Utilities::isPosInt($value)) { + $this->invalidValueError($field, $value); + } + break; + + case 'fieldMode': + if ($value !== 0 && $value !== 1) { + $this->invalidValueError($field, $value); + } + break; + + case 'key': + if (!preg_match('/^[23456789ABCDEFGHIJKMNPQRSTUVWXTZ]{8}$/', $value)) { + $this->invalidValueError($field, $value); + } + break; + + case 'dateAdded': + case 'dateModified': + if ($value !== '' && !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; + } + } + + + + private function invalidValueError($field, $value) { + throw new Exception("Invalid '$field' value '$value'"); + } +} +?> \ No newline at end of file diff --git a/model/old_Creators.inc.php b/model/old_Creators.inc.php new file mode 100644 index 00000000..85411445 --- /dev/null +++ b/model/old_Creators.inc.php @@ -0,0 +1,206 @@ +. + + ***** END LICENSE BLOCK ***** +*/ + +class Zotero_Creators extends Zotero_ClassicDataObjects { + public static $creatorSummarySortLength = 50; + + protected static $ZDO_object = 'creator'; + + protected static $primaryFields = array( + 'id' => 'creatorID', + 'libraryID' => '', + 'key' => '', + 'dateAdded' => '', + 'dateModified' => '', + 'firstName' => '', + 'lastName' => '', + 'fieldMode' => '' + ); + private static $fields = array( + 'firstName', 'lastName', 'fieldMode' + ); + + private static $maxFirstNameLength = 255; + private static $maxLastNameLength = 255; + + private static $creatorsByID = array(); + private static $primaryDataByCreatorID = array(); + private static $primaryDataByLibraryAndKey = array(); + + + public static function get($libraryID, $creatorID, $skipCheck=false) { + if (!$libraryID) { + throw new Exception("Library ID not set"); + } + + if (!$creatorID) { + throw new Exception("Creator ID not set"); + } + + if (!empty(self::$creatorsByID[$creatorID])) { + return self::$creatorsByID[$creatorID]; + } + + if (!$skipCheck) { + $sql = 'SELECT COUNT(*) FROM creators WHERE creatorID=?'; + $result = Zotero_DB::valueQuery($sql, $creatorID, Zotero_Shards::getByLibraryID($libraryID)); + if (!$result) { + return false; + } + } + + $creator = new Zotero_Creator; + $creator->libraryID = $libraryID; + $creator->id = $creatorID; + + self::$creatorsByID[$creatorID] = $creator; + return self::$creatorsByID[$creatorID]; + } + + + public static function getCreatorsWithData($libraryID, $creator, $sortByItemCountDesc=false) { + $sql = "SELECT creatorID, firstName, lastName FROM creators "; + if ($sortByItemCountDesc) { + $sql .= "LEFT JOIN itemCreators USING (creatorID) "; + } + $sql .= "WHERE libraryID=? AND firstName = ? " + . "AND lastName = ? AND fieldMode=?"; + if ($sortByItemCountDesc) { + $sql .= " GROUP BY creatorID ORDER BY IFNULL(COUNT(*), 0) DESC"; + } + $rows = Zotero_DB::query( + $sql, + array( + $libraryID, + $creator->firstName, + $creator->lastName, + $creator->fieldMode + ), + Zotero_Shards::getByLibraryID($libraryID) + ); + + // Case-sensitive filter, since the DB columns use a case-insensitive collation and we want + // it to use an index + $rows = array_filter($rows, function ($row) use ($creator) { + return $row['lastName'] == $creator->lastName && $row['firstName'] == $creator->firstName; + }); + + return array_column($rows, 'creatorID'); + } + + +/* + public static function updateLinkedItems($creatorID, $dateModified) { + Zotero_DB::beginTransaction(); + + // TODO: add to notifier, if we have one + //$sql = "SELECT itemID FROM itemCreators WHERE creatorID=?"; + //$changedItemIDs = Zotero_DB::columnQuery($sql, $creatorID); + + // This is very slow in MySQL 5.1.33 -- should be faster in MySQL 6 + //$sql = "UPDATE items SET dateModified=?, serverDateModified=? WHERE itemID IN + // (SELECT itemID FROM itemCreators WHERE creatorID=?)"; + + $sql = "UPDATE items JOIN itemCreators USING (itemID) SET items.dateModified=?, + items.serverDateModified=?, serverDateModifiedMS=? WHERE creatorID=?"; + $timestamp = Zotero_DB::getTransactionTimestamp(); + $timestampMS = Zotero_DB::getTransactionTimestampMS(); + Zotero_DB::query( + $sql, + array($dateModified, $timestamp, $timestampMS, $creatorID) + ); + Zotero_DB::commit(); + } +*/ + + public static function cache(Zotero_Creator $creator) { + if (isset(self::$creatorsByID[$creator->id])) { + error_log("Creator $creator->id is already cached"); + } + + self::$creatorsByID[$creator->id] = $creator; + } + + + public static function getLocalizedFieldNames($locale='en-US') { + if ($locale != 'en-US') { + throw new Exception("Locale not yet supported"); + } + + $fields = array('firstName', 'lastName', 'name'); + $rows = array(); + foreach ($fields as $field) { + $rows[] = array('name' => $field); + } + + foreach ($rows as &$row) { + switch ($row['name']) { + case 'firstName': + $row['localized'] = 'First'; + break; + + case 'lastName': + $row['localized'] = 'Last'; + break; + + case 'name': + $row['localized'] = 'Name'; + break; + } + } + + return $rows; + } + + + public static function purge() { + trigger_error("Unimplemented", E_USER_ERROR); + } + + + private static function convertXMLToDataValues(DOMElement $xml) { + $dataObj = new stdClass; + + $fieldMode = $xml->getElementsByTagName('fieldMode')->item(0); + $fieldMode = $fieldMode ? (int) $fieldMode->nodeValue : 0; + $dataObj->fieldMode = $fieldMode; + + if ($fieldMode == 1) { + $dataObj->firstName = ''; + $dataObj->lastName = $xml->getElementsByTagName('name')->item(0)->nodeValue; + } + else { + $dataObj->firstName = $xml->getElementsByTagName('firstName')->item(0)->nodeValue; + $dataObj->lastName = $xml->getElementsByTagName('lastName')->item(0)->nodeValue; + } + + $birthYear = $xml->getElementsByTagName('birthYear')->item(0); + $dataObj->birthYear = $birthYear ? $birthYear->nodeValue : null; + + return $dataObj; + } +} +?> \ No newline at end of file diff --git a/model/old_Item.inc.php b/model/old_Item.inc.php new file mode 100644 index 00000000..bdd1a71a --- /dev/null +++ b/model/old_Item.inc.php @@ -0,0 +1,5041 @@ +. + + ***** END LICENSE BLOCK ***** +*/ + +class Zotero_Item extends Zotero_DataObject { + protected $objectType = 'item'; + protected $dataTypesExtended = [ + 'itemData', + 'note', + 'creators', + 'childItems', + 'tags', + 'collections', + 'relations' + ]; + + protected $_itemTypeID; + protected $_dateAdded; + protected $_dateModified; + protected $_serverDateModified; + + private $itemData = array(); + private $creators = array(); + private $creatorSummary; + + private $sourceItem; + private $noteTitle = null; + private $noteText = null; + private $noteTextSanitized = null; + + private $inPublications = null; + + private $attachmentData = array( + 'linkMode' => null, + 'mimeType' => null, + 'charset' => null, + 'path' => null, + 'filename' => null, + 'storageModTime' => null, + 'storageHash' => null, + ); + + private $annotationData = [ + 'type' => null, + 'authorName' => null, + 'text' => null, + 'comment' => null, + 'color' => null, + 'pageLabel' => null, + 'sortIndex' => null, + 'position' => null + ]; + private $annotationTitle = null; + + private $numNotes; + private $numAttachments; + private $numAnnotations; + + protected $collections = []; + protected $tags = []; + + public function __construct($itemTypeOrID=false) { + parent::__construct(); + + if ($itemTypeOrID) { + $this->setField("itemTypeID", Zotero_ItemTypes::getID($itemTypeOrID)); + } + } + + + public function __get($field) { + // Inline libraryID, id, and key for performance + if ($field == 'libraryID') { + return $this->_libraryID; + } + if ($field == 'id') { + if (!$this->_id && $this->_key && !$this->loaded['primaryData']) { + $this->loadPrimaryData(); + } + return $this->_id; + } + if ($field == 'key') { + if (!$this->_key && $this->_id && !$this->loaded['primaryData']) { + $this->loadPrimaryData(); + } + return $this->_key; + } + + if (Zotero_Items::isPrimaryField($field)) { + if (!property_exists('Zotero_Item', "_$field")) { + throw new Exception("Zotero_Item property '$field' doesn't exist"); + } + return $this->getField($field); + } + + switch ($field) { + case 'libraryKey': + return $this->libraryID . "/" . $this->key; + + case 'creatorSummary': + return $this->getCreatorSummary(); + + case 'inPublications': + return $this->getPublications(); + + case 'createdByUserID': + return $this->getCreatedByUserID(); + + case 'lastModifiedByUserID': + return $this->getLastModifiedByUserID(); + + case 'attachmentLinkMode': + return $this->getAttachmentLinkMode(); + + case 'attachmentContentType': + return $this->getAttachmentMIMEType(); + + // Deprecated + case 'attachmentMIMEType': + return $this->getAttachmentMIMEType(); + + case 'attachmentCharset': + return $this->getAttachmentCharset(); + + case 'attachmentFilename': + return $this->getAttachmentFilename(); + + case 'attachmentPath': + case 'attachmentStorageModTime': + case 'attachmentStorageHash': + // Strip 'attachment' + $field = substr($field, 10); + $field[0] = strtolower($field[0]); + return $this->getAttachmentField($field); + + case 'annotationType': + case 'annotationAuthorName': + case 'annotationText': + case 'annotationComment': + case 'annotationColor': + case 'annotationPageLabel': + case 'annotationSortIndex': + case 'annotationPosition': + // Strip 'annotation' + $field = substr($field, 10); + $field[0] = strtolower($field[0]); + return $this->getAnnotationField($field); + + case 'relatedItems': + return $this->getRelatedItems(); + + case 'etag': + return $this->getETag(); + + default: + return parent::__get($field); + } + + throw new Exception("'$field' is not a primary or attachment field"); + } + + + public function __set($field, $val) { + //Z_Core::debug("Setting field $field to '$val'"); + + if ($field == 'id' || Zotero_Items::isPrimaryField($field)) { + if (!property_exists('Zotero_Item', "_$field")) { + throw new Exception("'$field' is not a valid Zotero_Item property"); + } + return $this->setField($field, $val); + } + + switch ($field) { + case 'deleted': + return $this->setDeleted($val); + + case 'inPublications': + return $this->setPublications($val); + + case 'attachmentLinkMode': + case 'attachmentCharset': + case 'attachmentStorageModTime': + case 'attachmentStorageHash': + case 'attachmentPath': + case 'attachmentFilename': + $field = substr($field, 10); + $field[0] = strtolower($field[0]); + return $this->setAttachmentField($field, $val); + + case 'attachmentContentType': + // Deprecated + case 'attachmentMIMEType': + return $this->setAttachmentField('mimeType', $val); + + case 'annotationType': + case 'annotationAuthorName': + case 'annotationText': + case 'annotationComment': + case 'annotationColor': + case 'annotationPageLabel': + case 'annotationSortIndex': + case 'annotationPosition': + $field = substr($field, 10); + $field[0] = strtolower($field[0]); + return $this->setAnnotationField($field, $val); + + case 'relatedItems': + return $this->setRelatedItems($val); + } + + throw new Exception("'$field' is not a valid Zotero_Item property"); + } + + + public function getField($field, $unformatted=false, $includeBaseMapped=false, $skipValidation=false) { + //Z_Core::debug("Requesting field '$field' for item $this->id", 4); + + if (($this->_id || $this->_key) && !$this->loaded['primaryData']) { + $this->loadPrimaryData(); + } + + if ($field == 'id' || Zotero_Items::isPrimaryField($field)) { + //Z_Core::debug("Returning '" . $this->{"_$field"} . "' for field $field", 4); + return $this->{"_$field"}; + } + + if ($this->isNote()) { + switch ($field) { + case 'title': + return $this->getNoteTitle(); + + default: + return ''; + } + } + else if ($this->isAnnotation()) { + switch ($field) { + case 'title': + return $this->getAnnotationTitle(); + } + } + + if ($includeBaseMapped) { + $fieldID = Zotero_ItemFields::getFieldIDFromTypeAndBase( + $this->itemTypeID, $field + ); + } + + if (empty($fieldID)) { + $fieldID = Zotero_ItemFields::getID($field); + } + + // If field is not valid for this (non-custom) type, return empty string + if (!Zotero_ItemTypes::isCustomType($this->itemTypeID) + && !Zotero_ItemFields::isCustomField($fieldID) + && !array_key_exists($fieldID, $this->itemData)) { + $msg = "Field '$field' doesn't exist for item $this->libraryID/$this->key of type {$this->itemTypeID}"; + if (!$skipValidation) { + throw new Exception($msg); + } + Z_Core::debug($msg . " -- returning ''", 4); + return ''; + } + + if ($this->id && is_null($this->itemData[$fieldID]) && !$this->loaded['itemData']) { + $this->loadItemData(); + } + + $value = $this->itemData[$fieldID] !== false ? $this->itemData[$fieldID] : ''; + + if (!$unformatted) { + // Multipart date fields + if (Zotero_ItemFields::isDate($fieldID)) { + $value = Zotero_Date::multipartToStr($value); + } + } + + //Z_Core::debug("Returning '$value' for field $field", 4); + return $value; + } + + + public function getDisplayTitle($includeAuthorAndDate=false) { + $title = $this->getField('title', false, true); + $itemTypeID = $this->itemTypeID; + + $itemTypeLetter = Zotero_ItemTypes::getID('letter'); + $itemTypeInterview = Zotero_ItemTypes::getID('interview'); + $itemTypeCase = Zotero_ItemTypes::getID('case'); + + $creatorTypeAuthor = Zotero_CreatorTypes::getID('author'); + $creatorTypeRecipient = Zotero_CreatorTypes::getID('recipient'); + $creatorTypeInterviewer = Zotero_CreatorTypes::getID('interviewer'); + $creatorTypeInterviewee = Zotero_CreatorTypes::getID('interviewee'); + + // 'letter' or 'interview' + if (!$title && ($itemTypeID == $itemTypeLetter || $itemTypeID == $itemTypeInterview)) { + $creators = $this->getCreators(); + $authors = array(); + $participants = array(); + if ($creators) { + foreach ($creators as $creator) { + if (($itemTypeID == $itemTypeLetter && $creator['creatorTypeID'] == $creatorTypeRecipient) || + ($itemTypeID == $itemTypeInterview && $creator['creatorTypeID'] == $creatorTypeInterviewer)) { + $participants[] = $creator; + } + else if (($itemTypeID == $itemTypeLetter && $creator['creatorTypeID'] == $creatorTypeAuthor) || + ($itemTypeID == $itemTypeInterview && $creator['creatorTypeID'] == $creatorTypeInterviewee)) { + $authors[] = $creator; + } + } + } + + $strParts = array(); + + if ($includeAuthorAndDate) { + $names = array(); + foreach($authors as $author) { + $names[] = $author['ref']->lastName; + } + + // TODO: Use same logic as getFirstCreatorSQL() (including "et al.") + if ($names) { + // TODO: was localeJoin() in client + $strParts[] = implode(', ', $names); + } + } + + if ($participants) { + $names = array(); + foreach ($participants as $participant) { + $names[] = $participant['ref']->lastName; + } + switch (sizeOf($names)) { + case 1: + //$str = 'oneParticipant'; + $nameStr = $names[0]; + break; + + case 2: + //$str = 'twoParticipants'; + $nameStr = "{$names[0]} and {$names[1]}"; + break; + + case 3: + //$str = 'threeParticipants'; + $nameStr = "{$names[0]}, {$names[1]}, and {$names[2]}"; + break; + + default: + //$str = 'manyParticipants'; + $nameStr = "{$names[0]} et al."; + } + + /* + pane.items.letter.oneParticipant = Letter to %S + pane.items.letter.twoParticipants = Letter to %S and %S + pane.items.letter.threeParticipants = Letter to %S, %S, and %S + pane.items.letter.manyParticipants = Letter to %S et al. + pane.items.interview.oneParticipant = Interview by %S + pane.items.interview.twoParticipants = Interview by %S and %S + pane.items.interview.threeParticipants = Interview by %S, %S, and %S + pane.items.interview.manyParticipants = Interview by %S et al. + */ + + //$strParts[] = Zotero.getString('pane.items.' + itemTypeName + '.' + str, names); + + $loc = Zotero_ItemTypes::getLocalizedString($itemTypeID); + // Letter + if ($itemTypeID == $itemTypeLetter) { + $loc .= ' to '; + } + // Interview + else { + $loc .= ' by '; + } + $strParts[] = $loc . $nameStr; + + } + else { + $strParts[] = Zotero_ItemTypes::getLocalizedString($itemTypeID); + } + + if ($includeAuthorAndDate) { + $d = $this->getField('date'); + if ($d) { + $strParts[] = $d; + } + } + + $title = '['; + $title .= join('; ', $strParts); + $title .= ']'; + } + // 'case' + else if ($itemTypeID == $itemTypeCase) { + if ($title) { + $reporter = $this->getField('reporter'); + if ($reporter) { + $title = $title . ' (' . $reporter . ')'; + } + } + else { // civil law cases have only shortTitle as case name + $strParts = array(); + $caseinfo = ""; + + $part = $this->getField('court'); + if ($part) { + $strParts[] = $part; + } + + $part = Zotero_Date::multipartToSQL($this->getField('date', true, true)); + if ($part) { + $strParts[] = $part; + } + + $creators = $this->getCreators(); + if ($creators && $creators[0]['creatorTypeID'] === $creatorTypeAuthor) { + $strParts[] = $creators[0]['ref']->lastName; + } + + $title = '[' . implode(', ', $strParts) . ']'; + } + } + + return $title; + } + + + /** + * Returns all fields used in item + * + * @param bool $asNames Return as field names + * @return array Array of field ids or names + */ + public function getUsedFields($asNames=false) { + if (!$this->id) { + return array(); + } + + $sql = "SELECT fieldID FROM itemData WHERE itemID=?"; + $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID)); + $fields = Zotero_DB::columnQueryFromStatement($stmt, $this->id); + if (!$fields) { + $fields = array(); + } + + if ($asNames) { + $fieldNames = array(); + foreach ($fields as $field) { + $fieldNames[] = Zotero_ItemFields::getName($field); + } + $fields = $fieldNames; + } + + return $fields; + } + + + /** + * Check if item exists in the database + * + * @return bool TRUE if the item exists, FALSE if not + */ + public function exists() { + if (!$this->id) { + throw new Exception('$this->id not set'); + } + + $sql = "SELECT COUNT(*) FROM items WHERE itemID=?"; + return !!Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); + } + + + private function load($allowFail=false) { + $this->loadPrimaryData(false, !$allowFail); + $this->loadItemData(); + $this->loadCreators(); + } + + + public function loadFromRow($row, $reload=false) { + // If necessary or reloading, set the type and reinitialize $this->itemData + if ($reload || (!$this->_itemTypeID && !empty($row['itemTypeID']))) { + $this->setType($row['itemTypeID'], true); + } + + foreach ($row as $field => $val) { + if (!Zotero_Items::isPrimaryField($field)) { + Z_Core::debug("'$field' is not a valid primary field", 1); + } + + //Z_Core::debug("Setting field '$field' to '$val' for item " . $this->id); + switch ($field) { + case 'itemTypeID': + $this->setType($val, true); + break; + + default: + $this->{"_$field"} = $val; + } + } + + $this->loaded['primaryData'] = true; + $this->clearChanged('primaryData'); + $this->identified = true; + } + + + /** + * @param {Integer} $itemTypeID itemTypeID to change to + * @param {Boolean} [$loadIn=false] Internal call, so don't flag field as changed + */ + private function setType($itemTypeID, $loadIn=false) { + if ($itemTypeID == $this->_itemTypeID) { + return true; + } + + // TODO: block switching to/from note or attachment + + if (!Zotero_ItemTypes::getID($itemTypeID)) { + throw new Exception("Invalid itemTypeID", Z_ERROR_INVALID_INPUT); + } + + $copiedFields = array(); + + $oldItemTypeID = $this->_itemTypeID; + + if ($oldItemTypeID) { + if ($loadIn) { + throw new Exception('Cannot change type in loadIn mode'); + } + if (!$this->loaded['itemData'] && $this->id) { + $this->loadItemData(); + } + + $obsoleteFields = $this->getFieldsNotInType($itemTypeID); + if ($obsoleteFields) { + foreach($obsoleteFields as $oldFieldID) { + // Try to get a base type for this field + $baseFieldID = + Zotero_ItemFields::getBaseIDFromTypeAndField($this->_itemTypeID, $oldFieldID); + + if ($baseFieldID) { + $newFieldID = + Zotero_ItemFields::getFieldIDFromTypeAndBase($itemTypeID, $baseFieldID); + + // If so, save value to copy to new field + if ($newFieldID) { + $copiedFields[] = array($newFieldID, $this->getField($oldFieldID)); + } + } + + // Clear old field + $this->setField($oldFieldID, false); + } + } + + foreach ($this->itemData as $fieldID => $value) { + if (!is_null($this->itemData[$fieldID]) && + (!$obsoleteFields || !in_array($fieldID, $obsoleteFields))) { + $copiedFields[] = array($fieldID, $this->getField($fieldID)); + } + } + } + + $this->_itemTypeID = $itemTypeID; + + if ($oldItemTypeID) { + // Reset custom creator types to the default + $creators = $this->getCreators(); + if ($creators) { + foreach ($creators as $orderIndex=>$creator) { + if (Zotero_CreatorTypes::isCustomType($creator['creatorTypeID'])) { + continue; + } + if (!Zotero_CreatorTypes::isValidForItemType($creator['creatorTypeID'], $itemTypeID)) { + // TODO: port + + // Reset to contributor (creatorTypeID 2), which exists in all + $this->setCreator($orderIndex, $creator['ref'], 2); + } + } + } + + } + + // If not custom item type, initialize $this->itemData with type-specific fields + $this->itemData = array(); + if (!Zotero_ItemTypes::isCustomType($itemTypeID)) { + $fields = Zotero_ItemFields::getItemTypeFields($itemTypeID); + foreach($fields as $fieldID) { + $this->itemData[$fieldID] = null; + } + } + + if ($copiedFields) { + foreach($copiedFields as $copiedField) { + $this->setField($copiedField[0], $copiedField[1]); + } + } + + if ($loadIn) { + $this->loaded['itemData'] = false; + } + else { + $this->changed['primaryData']['itemTypeID'] = true; + } + + return true; + } + + + /* + * Find existing fields from current type that aren't in another + * + * If _allowBaseConversion_, don't return fields that can be converted + * via base fields (e.g. label => publisher => studio) + */ + private function getFieldsNotInType($itemTypeID, $allowBaseConversion=false) { + $fieldIDs = array(); + + foreach ($this->itemData as $fieldID => $val) { + if (!is_null($val)) { + if (Zotero_ItemFields::isValidForType($fieldID, $itemTypeID)) { + continue; + } + + if ($allowBaseConversion) { + $baseID = Zotero_ItemFields::getBaseIDFromTypeAndField($this->itemTypeID, $fieldID); + if ($baseID) { + $newFieldID = Zotero_ItemFields::getFieldIDFromTypeAndBase($itemTypeID, $baseID); + if ($newFieldID) { + continue; + } + } + } + $fieldIDs[] = $fieldID; + } + } + + if (!$fieldIDs) { + return false; + } + + return $fieldIDs; + } + + + + /** + * @param string|int $field Field name or ID + * @param mixed $value Field value + * @param bool $loadIn Populate the data fields without marking as changed + */ + public function setField($field, $value, $loadIn=false) { + if (is_string($value)) { + $value = trim($value); + } + + if (empty($field)) { + throw new Exception("Field not specified"); + } + + if ($field == 'id' || $field == 'libraryID' || $field == 'key') { + return $this->setIdentifier($field, $value); + } + + if (($this->_id || $this->_key) && !$this->loaded['primaryData']) { + $this->loadPrimaryData(); + } + + // Primary field + if (Zotero_Items::isPrimaryField($field)) { + if ($loadIn) { + throw new Exception("Cannot set primary field $field in loadIn mode"); + } + + switch ($field) { + case 'itemTypeID': + break; + + case 'dateAdded': + case 'dateModified': + if (Zotero_Date::isISO8601($value)) { + $value = Zotero_Date::iso8601ToSQL($value); + } + break; + + case 'version': + $value = (int) $value; + break; + + case 'synced': + $value = !!$value; + + default: + throw new Exception("Primary field $field cannot be changed"); + } + + if ($this->{"_$field"} === $value) { + Z_Core::debug("Field '$field' has not changed", 4); + return false; + } + + Z_Core::debug("Field $field has changed from " . $this->{"_$field"} . " to $value", 4); + + if ($field == 'itemTypeID') { + $this->setType($value, $loadIn); + } + else { + $this->{"_$field"} = $value; + $this->changed['primaryData'][$field] = true; + } + return true; + } + + // + // itemData field + // + if ($field == 'accessDate' && Zotero_Date::isISO8601($value)) { + $value = Zotero_Date::iso8601ToSQL($value); + } + + if (!$this->_itemTypeID) { + trigger_error('Item type must be set before setting field data', E_USER_ERROR); + } + + // If existing item, load field data first unless we're already in + // the middle of a load + if ($this->_id) { + if (!$loadIn && !$this->loaded['itemData']) { + $this->loadItemData(); + } + } + else { + $this->loaded['itemData'] = true; + } + + $fieldID = Zotero_ItemFields::getID($field); + + if (!$fieldID) { + throw new Exception("'$field' is not a valid itemData field", Z_ERROR_INVALID_INPUT); + } + + if ($value === "" || $value === null) { + $value = false; + } + + if ($value !== false && !Zotero_ItemFields::isValidForType($fieldID, $this->_itemTypeID)) { + $fieldName = Zotero_ItemFields::getName($fieldID); + throw new Exception("'$fieldName' is not a valid field for type '" + . Zotero_ItemTypes::getName($this->_itemTypeID) . "'", Z_ERROR_INVALID_INPUT); + } + + if (!$loadIn) { + // Save date field as multipart date + if (Zotero_ItemFields::isDate($fieldID) && !Zotero_Date::isMultipart($value)) { + $value = Zotero_Date::strToMultipart($value); + if ($value === "") { + $value = false; + } + } + // Validate access date + else if ($fieldID == Zotero_ItemFields::getID('accessDate')) { + if ($value && (!Zotero_Date::isSQLDate($value) && + !Zotero_Date::isSQLDateTime($value) && + $value != 'CURRENT_TIMESTAMP')) { + Z_Core::debug("Discarding invalid accessDate '" . $value . "'"); + return false; + } + } + + // If existing value, make sure it's actually changing + if ((!isset($this->itemData[$fieldID]) && $value === false) || + (isset($this->itemData[$fieldID]) && $this->itemData[$fieldID] === $value)) { + return false; + } + + //Z_Core::debug("Field $field has changed from {$this->itemData[$fieldID]} to $value", 4); + + // TODO: Save a copy of the object before modifying? + } + + $this->itemData[$fieldID] = $value; + + if (!$loadIn) { + if (!isset($changed['itemData'])) { + $changed['itemData'] = []; + } + $this->changed['itemData'][$fieldID] = true; + } + return true; + } + + + public function isNote() { + return Zotero_ItemTypes::getName($this->getField('itemTypeID')) == 'note'; + } + + + public function isAttachment() { + return Zotero_ItemTypes::getName($this->getField('itemTypeID')) == 'attachment'; + } + + + public function isFileAttachment() { + if (!$this->isAttachment()) return false; + $name = $this->attachmentLinkMode; + return $name != "linked_url"; + } + + + public function isImportedAttachment() { + if (!$this->isAttachment()) return false; + $name = $this->attachmentLinkMode; + return $name == "imported_file" || $name == "imported_url"; + } + + + public function isStoredFileAttachment() { + if (!$this->isAttachment()) return false; + $name = $this->attachmentLinkMode; + return $name == "imported_file" || $name == "imported_url" || $name == "embedded_image"; + } + + + public function isPDFAttachment() { + if (!$this->isFileAttachment()) return false; + return $this->attachmentContentType == 'application/pdf'; + } + + + public function isEmbeddedImageAttachment() { + if (!$this->isAttachment()) return false; + $name = $this->attachmentLinkMode; + return $name == "embedded_image"; + } + + + public function isAnnotation() { + return Zotero_ItemTypes::getName($this->getField('itemTypeID')) == 'annotation'; + } + + + private function getCreatorSummary() { + if ($this->creatorSummary !== null) { + return $this->creatorSummary; + } + + if ($this->cacheEnabled) { + $cacheVersion = 1; + $cacheKey = $this->getCacheKey("creatorSummary", + $cacheVersion + . isset(Z_CONFIG::$CACHE_VERSION_ITEM_DATA) + ? "_" . Z_CONFIG::$CACHE_VERSION_ITEM_DATA + : "" + ); + if ($cacheKey) { + $creatorSummary = Z_Core::$MC->get($cacheKey); + if ($creatorSummary !== false) { + $this->creatorSummary = $creatorSummary; + return $creatorSummary; + } + } + } + + $itemTypeID = $this->getField('itemTypeID'); + $creators = $this->getCreators(); + + $creatorTypeIDsToTry = array( + // First try for primary creator types + Zotero_CreatorTypes::getPrimaryIDForType($itemTypeID), + // Then try editors + Zotero_CreatorTypes::getID('editor'), + // Then try contributors + Zotero_CreatorTypes::getID('contributor') + ); + + $localizedAnd = " and "; + $etAl = " et al."; + + $creatorSummary = ''; + foreach ($creatorTypeIDsToTry as $creatorTypeID) { + $loc = array(); + foreach ($creators as $orderIndex=>$creator) { + if ($creator['creatorTypeID'] == $creatorTypeID) { + $loc[] = $orderIndex; + + if (sizeOf($loc) == 3) { + break; + } + } + } + + switch (sizeOf($loc)) { + case 0: + continue 2; + + case 1: + $creatorSummary = $creators[$loc[0]]['ref']->lastName; + break; + + case 2: + $creatorSummary = $creators[$loc[0]]['ref']->lastName + . $localizedAnd + . $creators[$loc[1]]['ref']->lastName; + break; + + case 3: + $creatorSummary = $creators[$loc[0]]['ref']->lastName . $etAl; + break; + } + + break; + } + + if ($this->cacheEnabled && $cacheKey) { + Z_Core::$MC->set($cacheKey, $creatorSummary); + } + + $this->creatorSummary = $creatorSummary; + return $creatorSummary; + } + + + private function getPublications() { + if ($this->inPublications !== null) { + return $this->inPublications; + } + + if (!$this->__get('id')) { + return false; + } + + if (!is_numeric($this->id)) { + throw new Exception("Invalid itemID"); + } + + if ($this->cacheEnabled) { + $cacheVersion = 2; + $cacheKey = $this->getCacheKey("itemInPublications", $cacheVersion); + $inPublications = Z_Core::$MC->get($cacheKey); + } + else { + $inPublications = false; + } + if ($inPublications === false) { + // Only user items can be in My Publications + $libraryType = Zotero_Libraries::getType($this->libraryID); + if ($libraryType != 'user') { + $inPublications = false; + } + else { + $sql = "SELECT COUNT(*) FROM publicationsItems WHERE itemID=?"; + $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID)); + $inPublications = !!Zotero_DB::valueQueryFromStatement($stmt, $this->id); + } + + // Memcache returns false for empty keys, so use integer + if ($this->cacheEnabled) { + Z_Core::$MC->set($cacheKey, $inPublications ? 1 : 0); + } + } + + return $this->inPublications = $inPublications; + } + + + private function setPublications($val) { + $inPublications = !!$val; + + if ($this->getPublications() == $inPublications) { + Z_Core::debug("Publications state ($inPublications) hasn't changed for item $this->id"); + return; + } + + if (empty($this->changed['inPublications'])) { + $this->changed['inPublications'] = true; + } + $this->inPublications = $inPublications; + } + + + private function getCreatedByUserID() { + $sql = "SELECT createdByUserID FROM groupItems WHERE itemID=?"; + return Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); + } + + + private function getLastModifiedByUserID() { + $sql = "SELECT lastModifiedByUserID FROM groupItems WHERE itemID=?"; + return Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID)); + } + + + public function save($userID=false) { + if (!$this->_libraryID) { + trigger_error("Library ID must be set before saving", E_USER_ERROR); + } + + Zotero_Items::editCheck($this, $userID); + + if (!$this->hasChanged()) { + Z_Core::debug("Item $this->id has not changed"); + return false; + } + + $this->cacheEnabled = false; + + // Make sure there are no gaps in the creator indexes + $creators = $this->getCreators(); + $lastPos = -1; + foreach ($creators as $pos=>$creator) { + if ($pos != $lastPos + 1) { + trigger_error("Creator index $pos out of sequence for item $this->id", E_USER_ERROR); + } + $lastPos++; + } + + // Disabled (see function comment) + //$this->checkTopLevelAttachment(); + + $shardID = Zotero_Shards::getByLibraryID($this->_libraryID); + $isGroupLibrary = Zotero_Libraries::getType($this->_libraryID) == 'group'; + + $env = []; + + Zotero_DB::beginTransaction(); + + try { + // + // New item, insert and return id + // + if (!$this->id || (empty($this->changed['version']) && !$this->exists())) { + Z_Core::debug('Saving data for new item to database'); + + $isNew = $env['isNew'] = true; + $sqlColumns = array(); + $sqlValues = array(); + + // Fail fast on missing parents, so we don't burn ids or do unnecessary work + if ($this->isAttachment() || $this->isNote() || $this->isAnnotation()) { + $this->getSource(); + } + + // + // Primary fields + // + $itemID = $this->_id = $this->_id ? $this->_id : Zotero_ID::get('items'); + $key = $this->_key = $this->_key ? $this->_key : Zotero_ID::getKey(); + + $sqlColumns = array( + 'itemID', + 'itemTypeID', + 'libraryID', + 'key', + 'dateAdded', + 'dateModified', + 'serverDateModified', + 'version' + ); + $timestamp = Zotero_DB::getTransactionTimestamp(); + $dateAdded = $this->_dateAdded ? $this->_dateAdded : $timestamp; + $dateModified = $this->_dateModified ? $this->_dateModified : $timestamp; + $version = Zotero_Libraries::getUpdatedVersion($this->_libraryID); + $sqlValues = array( + $itemID, + $this->_itemTypeID, + $this->_libraryID, + $key, + $dateAdded, + $dateModified, + $timestamp, + $version + ); + + $sql = 'INSERT INTO items (`' . implode('`, `', $sqlColumns) . '`) VALUES ('; + // Insert placeholders for bind parameters + for ($i=0; $igetMessage(), "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(""); + $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 00000000..ea7cf334
--- /dev/null
+++ b/model/old_Items.inc.php
@@ -0,0 +1,2587 @@
+.
+    
+    ***** 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(
+					'<note></note>',
+					'<note/>',
+					$uncached
+				);
+				$uncached = str_replace(
+					'<path></path>',
+					'<path/>',
+					$uncached
+				);
+				$uncached = str_replace(
+					'<td></td>',
+					'<td/>',
+					$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 00000000..2b68e4d2
--- /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 <http://www.gnu.org/licenses/>.
+    
+    ***** 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 00000000..2f4aac08
--- /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 <http://www.gnu.org/licenses/>.
+    
+    ***** 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 version="1.0" encoding="UTF-8"?>'
+			. '<entry xmlns="' . Zotero_Atom::$nsAtom
+			. '" xmlns:zapi="' . Zotero_Atom::$nsZoteroAPI . '"/>'
+		);
+		
+		$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("<div/>");
+			$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 00000000..763c810c
--- /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 <http://www.gnu.org/licenses/>.
+    
+    ***** 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
" . $note . "