From 9a15ff70a143979d87971768552485971cfa4011 Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Fri, 21 Jul 2023 23:34:18 +0000 Subject: [PATCH 01/13] creator as non-classic data object --- .../2023-07-17/classic_data_objects.sql | 14 ++ misc/master.sql | 1 + misc/shard.sql | 2 +- model/Creator.inc.php | 146 ++---------------- model/Creators.inc.php | 53 ++++--- model/Item.inc.php | 29 +++- model/Items.inc.php | 17 +- 7 files changed, 90 insertions(+), 172 deletions(-) create mode 100644 misc/db-updates/2023-07-17/classic_data_objects.sql diff --git a/misc/db-updates/2023-07-17/classic_data_objects.sql b/misc/db-updates/2023-07-17/classic_data_objects.sql new file mode 100644 index 00000000..f789154b --- /dev/null +++ b/misc/db-updates/2023-07-17/classic_data_objects.sql @@ -0,0 +1,14 @@ +alter table itemCreators drop constraint itemCreators_ibfk_2; +DROP table creators; + +DROP trigger if exists fki_itemCreators_libraryID; +DROP trigger if exists fku_itemCreators_libraryID; + +CREATE TABLE `creators` ( + `creatorID` bigint unsigned NOT NULL AUTO_INCREMENT, + `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, + PRIMARY KEY (`creatorID`), + KEY `name` (`lastName`(7),`firstName`(6)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file diff --git a/misc/master.sql b/misc/master.sql index 4c79cd11..e33002d5 100644 --- a/misc/master.sql +++ b/misc/master.sql @@ -184,6 +184,7 @@ CREATE TABLE `libraries` ( `lastUpdated` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', `version` int(10) unsigned NOT NULL DEFAULT '0', `shardID` smallint(5) unsigned NOT NULL, + `hasData` TINYINT( 1 ) NOT NULL DEFAULT '0', PRIMARY KEY (`libraryID`), KEY `shardID` (`shardID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 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/Creator.inc.php b/model/Creator.inc.php index a60c3268..58e27691 100644 --- a/model/Creator.inc.php +++ b/model/Creator.inc.php @@ -27,40 +27,27 @@ 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; - + public function __construct($id, $libraryID, $firstName, $lastName, $fieldMode) { + $this->id = $id; + $this->libraryID = $libraryID; + $this->firstName = $firstName; + $this->lastName = $lastName; + $this->fieldMode = $fieldMode; $this->changed = array(); $props = array( + 'libraryID', 'firstName', 'lastName', 'shortName', - 'fieldMode', - 'birthYear', - 'dateAdded', - 'dateModified' + 'fieldMode' ); foreach ($props as $prop) { $this->changed[$prop] = false; @@ -69,10 +56,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"); } @@ -85,10 +69,6 @@ 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 +79,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,20 +88,6 @@ 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)); @@ -142,7 +99,7 @@ public function save($userID=false) { trigger_error("Library ID must be set before saving", E_USER_ERROR); } - Zotero_Creators::editCheck($this, $userID); + //Zotero_Creators::editCheck($this, $userID); // If empty, move on if ($this->firstName === '' && $this->lastName === '') { @@ -153,7 +110,7 @@ public function save($userID=false) { throw new Exception('First name must be empty in single-field mode'); } - if (!$this->hasChanged()) { + if (!$this->hasChanged() && isset($this->id)) { Z_Core::debug("Creator $this->id has not changed"); return false; } @@ -166,24 +123,13 @@ public function save($userID=false) { 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=?"; + $fields = "firstName=?, lastName=?, fieldMode=?"; $params = array( $this->firstName, $this->lastName, - $this->fieldMode, - $this->libraryID, - $key, - $dateAdded, - $dateModified, - $timestamp + $this->fieldMode ); $shardID = Zotero_Shards::getByLibraryID($this->libraryID); @@ -193,9 +139,6 @@ public function save($userID=false) { $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=?"; @@ -234,18 +177,6 @@ public function save($userID=false) { 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(); @@ -256,19 +187,13 @@ public function save($userID=false) { 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; } @@ -294,11 +219,7 @@ public function getLinkedItems() { } - public function equals($creator) { - if (!$this->loaded) { - $this->load(); - } - + public function equals($creator) { return ($creator->firstName === $this->firstName) && ($creator->lastName === $this->lastName) && @@ -306,41 +227,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'"); diff --git a/model/Creators.inc.php b/model/Creators.inc.php index 4e6d2590..af919e88 100644 --- a/model/Creators.inc.php +++ b/model/Creators.inc.php @@ -24,7 +24,7 @@ ***** END LICENSE BLOCK ***** */ -class Zotero_Creators extends Zotero_ClassicDataObjects { +class Zotero_Creators { public static $creatorSummarySortLength = 50; protected static $ZDO_object = 'creator'; @@ -32,9 +32,6 @@ class Zotero_Creators extends Zotero_ClassicDataObjects { protected static $primaryFields = array( 'id' => 'creatorID', 'libraryID' => '', - 'key' => '', - 'dateAdded' => '', - 'dateModified' => '', 'firstName' => '', 'lastName' => '', 'fieldMode' => '' @@ -50,8 +47,20 @@ class Zotero_Creators extends Zotero_ClassicDataObjects { private static $primaryDataByCreatorID = array(); private static $primaryDataByLibraryAndKey = array(); - - public static function get($libraryID, $creatorID, $skipCheck=false) { + public static function idsDoNotExist($libraryID, $creators) { + $creatorIDs = array_map(function ($object) { + return $object['creatorID']; + }, $creators); + $placeholders = implode(',', array_fill(0, count($creatorIDs), '?')); + $sql = "SELECT creatorID FROM creators WHERE creatorID IN ($placeholders)"; + $result = Zotero_DB::query($sql, $creatorIDs, Zotero_Shards::getByLibraryID($libraryID)); + $existingIDs = array_map(function ($object) { + return $object['creatorID']; + }, $result); + return array_diff($creatorIDs, $existingIDs); + } + + public static function get($libraryID, $creatorID) { if (!$libraryID) { throw new Exception("Library ID not set"); } @@ -64,17 +73,13 @@ public static function get($libraryID, $creatorID, $skipCheck=false) { 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; - } + $sql = 'SELECT * FROM creators WHERE creatorID=?'; + $creator = Zotero_DB::rowQuery($sql, $creatorID, Zotero_Shards::getByLibraryID($libraryID)); + if (!$creator) { + return false; } - $creator = new Zotero_Creator; - $creator->libraryID = $libraryID; - $creator->id = $creatorID; + $creator = new Zotero_Creator($creator['creatorID'], $libraryID, $creator['firstName'], $creator['lastName'], $creator['fieldMode'] ); self::$creatorsByID[$creatorID] = $creator; return self::$creatorsByID[$creatorID]; @@ -82,11 +87,11 @@ public static function get($libraryID, $creatorID, $skipCheck=false) { public static function getCreatorsWithData($libraryID, $creator, $sortByItemCountDesc=false) { - $sql = "SELECT creatorID, firstName, lastName FROM creators "; + $sql = "SELECT creatorID, firstName, lastName, fieldMode FROM creators "; if ($sortByItemCountDesc) { $sql .= "LEFT JOIN itemCreators USING (creatorID) "; } - $sql .= "WHERE libraryID=? AND firstName = ? " + $sql .= "WHERE firstName = ? " . "AND lastName = ? AND fieldMode=?"; if ($sortByItemCountDesc) { $sql .= " GROUP BY creatorID ORDER BY IFNULL(COUNT(*), 0) DESC"; @@ -94,7 +99,6 @@ public static function getCreatorsWithData($libraryID, $creator, $sortByItemCoun $rows = Zotero_DB::query( $sql, array( - $libraryID, $creator->firstName, $creator->lastName, $creator->fieldMode @@ -107,8 +111,17 @@ public static function getCreatorsWithData($libraryID, $creator, $sortByItemCoun $rows = array_filter($rows, function ($row) use ($creator) { return $row['lastName'] == $creator->lastName && $row['firstName'] == $creator->firstName; }); + + $result = []; + foreach($rows as $row) { + $c = new Zotero_Creator($row['creatorID'], $libraryID, $row['firstName'], $row['lastName'], $row['fieldMode'] ); + if (empty(self::$creatorsByID[$row['creatorID']])) { + self::$creatorsByID[$row['creatorID']] = $c; + } + array_push($result, $c); + } - return array_column($rows, 'creatorID'); + return $result; } @@ -197,8 +210,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..8bb8ad46 100644 --- a/model/Item.inc.php +++ b/model/Item.inc.php @@ -888,6 +888,7 @@ private function getCreatorSummary() { } $itemTypeID = $this->getField('itemTypeID'); + $this->loadCreators(true); $creators = $this->getCreators(); $creatorTypeIDsToTry = array( @@ -4716,7 +4717,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 +4741,8 @@ 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 + INNER JOIN creators USING (creatorID) 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 +4750,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,13 +4761,23 @@ protected function loadCreators($reload = false) { if (!$creators) { return; } - - foreach ($creators as $creator) { - $creatorObj = Zotero_Creators::get($this->libraryID, $creator['creatorID'], true); - if (!$creatorObj) { + + if ($cache_used) { + $creatorsNotFound = Zotero_Creators::idsDoNotExist($this->libraryID, $creators); + + foreach($creatorsNotFound as $missingCreator) { Z_Core::$MC->delete($cacheKey); throw new Exception("Creator {$creator['creatorID']} not found"); } + } + + $shardID = Zotero_Shards::getByLibraryID($this->_libraryID); + + // On update, we should have all this info already, so maybe we just get all data from + // Zotero_Creators::$creatorsByID instead of this extra query + loop + foreach ($creators as $creator) { + $creatorObj = new Zotero_Creator($creator['creatorID'], $shardID, $creator['firstName'], $creator['lastName'], $creator['fieldMode'] ); + $this->creators[$creator['orderIndex']] = array( 'creatorTypeID' => $creator['creatorTypeID'], 'ref' => $creatorObj diff --git a/model/Items.inc.php b/model/Items.inc.php index df08d0c6..721402c8 100644 --- a/model/Items.inc.php +++ b/model/Items.inc.php @@ -1749,26 +1749,17 @@ public static function updateFromJSON(Zotero_Item $item, } // 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; - } - + $newCreator = new Zotero_Creator(null, $item->libraryID, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode); + // 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); + $item->setCreator($orderIndex, $candidates[0], $newCreatorTypeID); continue; } // None found, so make a new one - $creatorID = $newCreator->save(); - $newCreator = Zotero_Creators::get($item->libraryID, $creatorID); + $newCreator->save(); $item->setCreator($orderIndex, $newCreator, $newCreatorTypeID); } From 0561025e8433af70cf52f3be2fcfc316ae903ea3 Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Fri, 21 Jul 2023 23:42:24 +0000 Subject: [PATCH 02/13] small libraryID fix --- model/Item.inc.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/model/Item.inc.php b/model/Item.inc.php index 8bb8ad46..58dd9ebb 100644 --- a/model/Item.inc.php +++ b/model/Item.inc.php @@ -4771,12 +4771,10 @@ protected function loadCreators($reload = false) { } } - $shardID = Zotero_Shards::getByLibraryID($this->_libraryID); - // On update, we should have all this info already, so maybe we just get all data from // Zotero_Creators::$creatorsByID instead of this extra query + loop foreach ($creators as $creator) { - $creatorObj = new Zotero_Creator($creator['creatorID'], $shardID, $creator['firstName'], $creator['lastName'], $creator['fieldMode'] ); + $creatorObj = new Zotero_Creator($creator['creatorID'], $this->_libraryID, $creator['firstName'], $creator['lastName'], $creator['fieldMode'] ); $this->creators[$creator['orderIndex']] = array( 'creatorTypeID' => $creator['creatorTypeID'], From e03e1dc462b11dfd7a3fa2b9befe821a228084db Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Mon, 24 Jul 2023 20:01:20 +0000 Subject: [PATCH 03/13] bulk-insert new creators + minor cleanup --- model/Creator.inc.php | 23 +++++++---------------- model/Creators.inc.php | 35 +++++++++++++++++++++++++++++++++++ model/Item.inc.php | 4 +--- model/Items.inc.php | 18 +++++++++++++----- model/Libraries.inc.php | 10 +++++++++- 5 files changed, 65 insertions(+), 25 deletions(-) diff --git a/model/Creator.inc.php b/model/Creator.inc.php index 58e27691..60dea62d 100644 --- a/model/Creator.inc.php +++ b/model/Creator.inc.php @@ -31,23 +31,26 @@ class Zotero_Creator { private $lastName = ''; private $shortName = ''; private $fieldMode = 0; + private $creatorTypeID; private $changed = array(); - public function __construct($id, $libraryID, $firstName, $lastName, $fieldMode) { + public function __construct($id, $libraryID, $firstName, $lastName, $fieldMode, $creatorTypeID = null) { $this->id = $id; $this->libraryID = $libraryID; $this->firstName = $firstName; $this->lastName = $lastName; $this->fieldMode = $fieldMode; + $this->creatorTypeID = $creatorTypeID; $this->changed = array(); $props = array( 'libraryID', 'firstName', 'lastName', 'shortName', - 'fieldMode' + 'fieldMode', + 'creatorTypeID' ); foreach ($props as $prop) { $this->changed[$prop] = false; @@ -99,7 +102,7 @@ public function save($userID=false) { trigger_error("Library ID must be set before saving", E_USER_ERROR); } - //Zotero_Creators::editCheck($this, $userID); + Zotero_Creators::editCheck($this, $userID); // If empty, move on if ($this->firstName === '' && $this->lastName === '') { @@ -236,6 +239,7 @@ private function checkValue($field, $value) { switch ($field) { case 'id': case 'libraryID': + case 'creatorTypeID': if (!Zotero_Utilities::isPosInt($value)) { $this->invalidValueError($field, $value); } @@ -246,19 +250,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 af919e88..5fdf1ff0 100644 --- a/model/Creators.inc.php +++ b/model/Creators.inc.php @@ -60,6 +60,30 @@ public static function idsDoNotExist($libraryID, $creators) { return array_diff($creatorIDs, $existingIDs); } + public static function bulkInsert($libraryID, $orderedCreators) { + $placeholdersArray = array(); + $paramList = array(); + foreach ($orderedCreators as $order => $creator) { + if (isset($creator->id)) { + throw new Exception("Insert not possible for creator with a set creatorID"); + } + $creator->id = Zotero_ID::get('creators'); + $placeholdersArray[] = "(?, ?, ?, ?)"; + $paramList = array_merge($paramList, [ + $creator->id, + $creator->firstName, + $creator->lastName, + $creator->fieldMode, + ]); + } + $placeholdersStr = implode(", ", $placeholdersArray); + $sql = "INSERT INTO creators (creatorID, firstName, lastName, fieldMode) VALUES $placeholdersStr"; + + $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($libraryID)); + Zotero_DB::queryFromStatement($stmt, $paramList); + return $orderedCreators; + } + public static function get($libraryID, $creatorID) { if (!$libraryID) { throw new Exception("Library ID not set"); @@ -156,6 +180,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') { diff --git a/model/Item.inc.php b/model/Item.inc.php index 58dd9ebb..43cdc4d3 100644 --- a/model/Item.inc.php +++ b/model/Item.inc.php @@ -888,7 +888,7 @@ private function getCreatorSummary() { } $itemTypeID = $this->getField('itemTypeID'); - $this->loadCreators(true); + $creators = $this->getCreators(); $creatorTypeIDsToTry = array( @@ -4771,8 +4771,6 @@ protected function loadCreators($reload = false) { } } - // On update, we should have all this info already, so maybe we just get all data from - // Zotero_Creators::$creatorsByID instead of this extra query + loop foreach ($creators as $creator) { $creatorObj = new Zotero_Creator($creator['creatorID'], $this->_libraryID, $creator['firstName'], $creator['lastName'], $creator['fieldMode'] ); diff --git a/model/Items.inc.php b/model/Items.inc.php index 721402c8..a8e63bda 100644 --- a/model/Items.inc.php +++ b/model/Items.inc.php @@ -1706,6 +1706,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' @@ -1749,19 +1750,26 @@ public static function updateFromJSON(Zotero_Item $item, } // Make a fake creator to use for the data lookup - $newCreator = new Zotero_Creator(null, $item->libraryID, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode); + $newCreator = new Zotero_Creator(null, $item->libraryID, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode, $newCreatorTypeID); - // Look for an equivalent creator in this library + // Look for an equivalent creator in this shard $candidates = Zotero_Creators::getCreatorsWithData($item->libraryID, $newCreator, true); if ($candidates) { $item->setCreator($orderIndex, $candidates[0], $newCreatorTypeID); continue; } - // None found, so make a new one - $newCreator->save(); - $item->setCreator($orderIndex, $newCreator, $newCreatorTypeID); + // None found, so prepare to make a new one + $creatorsToAdd[$orderIndex] = $newCreator; + } + // Save all new creators in bulk + if (count($creatorsToAdd) > 0) { + $addedCreators = Zotero_Creators::bulkInsert($item->libraryID, $creatorsToAdd); + foreach ($addedCreators as $order => $creator) { + $item->setCreator($order, $creator, $creator->creatorTypeID); + } } + // 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..10535727 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', 'creators', 'relations', 'savedSearches', 'tags', 'syncDeleteLogIDs', 'syncDeleteLogKeys', 'settings' ); @@ -386,6 +386,14 @@ public static function clearAllData($libraryID) { Zotero_FullText::deleteByLibraryMySQL($libraryID); foreach ($tables as $table) { + // Creators deleted after items + // We fetch and delete all creators that have no items linked to them + // In production, this should be a cron job + if ($table == 'creators') { + $sql = "DELETE creators from creators LEFT JOIN itemCreators USING (creatorID)"; + $deleteCreators = Zotero_DB::query($sql, [], $shardID); + continue; + } // For items, delete annotations first, then notes and attachments, then items after if ($table == 'items') { $itemTypeIDs = Zotero_DB::columnQuery( From 170704d9ddda2d0453d94e20aa81a210e9bf6847 Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Mon, 24 Jul 2023 23:45:55 +0000 Subject: [PATCH 04/13] store all data in itemCreators table --- .../2023-07-17/classic_data_objects.sql | 13 +- model/Cite.inc.php | 6 +- model/Creator.inc.php | 22 ++- model/Creators.inc.php | 21 +-- model/Item.inc.php | 158 +++++------------- model/Items.inc.php | 35 +--- model/Libraries.inc.php | 2 +- 7 files changed, 94 insertions(+), 163 deletions(-) diff --git a/misc/db-updates/2023-07-17/classic_data_objects.sql b/misc/db-updates/2023-07-17/classic_data_objects.sql index f789154b..d7acffc2 100644 --- a/misc/db-updates/2023-07-17/classic_data_objects.sql +++ b/misc/db-updates/2023-07-17/classic_data_objects.sql @@ -1,14 +1,19 @@ alter table itemCreators drop constraint itemCreators_ibfk_2; -DROP table creators; +DROP table if exists creators; +DROP table if exists itemCreators; DROP trigger if exists fki_itemCreators_libraryID; DROP trigger if exists fku_itemCreators_libraryID; -CREATE TABLE `creators` ( - `creatorID` bigint unsigned NOT NULL AUTO_INCREMENT, +CREATE TABLE `itemCreators` ( + `creatorID` bigint unsigned NOT NULL, + `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, - PRIMARY KEY (`creatorID`), + `creatorTypeID` smallint(5) unsigned NOT NULL, + `orderIndex` smallint(5) unsigned NOT NULL, + PRIMARY KEY (`creatorID`, `itemID`), + KEY `creatorTypeID` (`creatorTypeID`), KEY `name` (`lastName`(7),`firstName`(6)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file 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/Creator.inc.php b/model/Creator.inc.php index 60dea62d..a7a58e53 100644 --- a/model/Creator.inc.php +++ b/model/Creator.inc.php @@ -27,30 +27,36 @@ class Zotero_Creator { private $id; private $libraryID; + private $itemID; private $firstName = ''; private $lastName = ''; private $shortName = ''; private $fieldMode = 0; private $creatorTypeID; + private $orderIndex; private $changed = array(); - public function __construct($id, $libraryID, $firstName, $lastName, $fieldMode, $creatorTypeID = null) { + public function __construct($id, $libraryID, $itemID, $firstName, $lastName, $fieldMode, $creatorTypeID, $orderIndex) { $this->id = $id; $this->libraryID = $libraryID; + $this->itemID = $itemID; $this->firstName = $firstName; $this->lastName = $lastName; $this->fieldMode = $fieldMode; $this->creatorTypeID = $creatorTypeID; + $this->orderIndex = $orderIndex; $this->changed = array(); $props = array( 'libraryID', + 'itemID', 'firstName', 'lastName', 'shortName', 'fieldMode', - 'creatorTypeID' + 'creatorTypeID', + 'orderIndex' ); foreach ($props as $prop) { $this->changed[$prop] = false; @@ -72,6 +78,7 @@ public function __set($field, $value) { switch ($field) { case 'id': case 'libraryID': + case 'itemID': $this->checkValue($field, $value); $this->$field = $value; return; @@ -128,23 +135,26 @@ public function save($userID=false) { $timestamp = Zotero_DB::getTransactionTimestamp(); - $fields = "firstName=?, lastName=?, fieldMode=?"; + $fields = "itemID=?, firstName=?, lastName=?, fieldMode=?, creatorTypeID=?, orderIndex=?"; $params = array( + $this->itemID, $this->firstName, $this->lastName, - $this->fieldMode + $this->fieldMode, + $this->creatorTypeID, + $this->orderIndex ); $shardID = Zotero_Shards::getByLibraryID($this->libraryID); try { if ($isNew) { - $sql = "INSERT INTO creators SET creatorID=?, $fields"; + $sql = "INSERT INTO itemCreators SET creatorID=?, $fields"; $stmt = Zotero_DB::getStatement($sql, true, $shardID); Zotero_DB::queryFromStatement($stmt, array_merge(array($creatorID), $params)); } else { - $sql = "UPDATE creators SET $fields WHERE creatorID=?"; + $sql = "UPDATE itemCreators SET $fields WHERE creatorID=?"; $stmt = Zotero_DB::getStatement($sql, true, $shardID); Zotero_DB::queryFromStatement($stmt, array_merge($params, array($creatorID))); } diff --git a/model/Creators.inc.php b/model/Creators.inc.php index 5fdf1ff0..dd27e395 100644 --- a/model/Creators.inc.php +++ b/model/Creators.inc.php @@ -52,7 +52,7 @@ public static function idsDoNotExist($libraryID, $creators) { return $object['creatorID']; }, $creators); $placeholders = implode(',', array_fill(0, count($creatorIDs), '?')); - $sql = "SELECT creatorID FROM creators WHERE creatorID IN ($placeholders)"; + $sql = "SELECT creatorID FROM itemCreators WHERE creatorID IN ($placeholders)"; $result = Zotero_DB::query($sql, $creatorIDs, Zotero_Shards::getByLibraryID($libraryID)); $existingIDs = array_map(function ($object) { return $object['creatorID']; @@ -68,16 +68,19 @@ public static function bulkInsert($libraryID, $orderedCreators) { throw new Exception("Insert not possible for creator with a set creatorID"); } $creator->id = Zotero_ID::get('creators'); - $placeholdersArray[] = "(?, ?, ?, ?)"; + $placeholdersArray[] = "(?, ?, ?, ?, ?, ?, ?)"; $paramList = array_merge($paramList, [ $creator->id, + $creator->itemID, $creator->firstName, $creator->lastName, $creator->fieldMode, + $creator->creatorTypeID, + $creator->orderIndex, ]); } $placeholdersStr = implode(", ", $placeholdersArray); - $sql = "INSERT INTO creators (creatorID, firstName, lastName, fieldMode) VALUES $placeholdersStr"; + $sql = "INSERT INTO itemCreators (creatorID, itemID, firstName, lastName, fieldMode, creatorTypeID, orderIndex) VALUES $placeholdersStr"; $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($libraryID)); Zotero_DB::queryFromStatement($stmt, $paramList); @@ -97,7 +100,7 @@ public static function get($libraryID, $creatorID) { return self::$creatorsByID[$creatorID]; } - $sql = 'SELECT * FROM creators WHERE creatorID=?'; + $sql = 'SELECT * FROM itemCreators WHERE creatorID=?'; $creator = Zotero_DB::rowQuery($sql, $creatorID, Zotero_Shards::getByLibraryID($libraryID)); if (!$creator) { return false; @@ -111,12 +114,9 @@ public static function get($libraryID, $creatorID) { public static function getCreatorsWithData($libraryID, $creator, $sortByItemCountDesc=false) { - $sql = "SELECT creatorID, firstName, lastName, fieldMode FROM creators "; - if ($sortByItemCountDesc) { - $sql .= "LEFT JOIN itemCreators USING (creatorID) "; - } + $sql = "SELECT creatorID, firstName, lastName, fieldMode FROM itemCreators "; $sql .= "WHERE firstName = ? " - . "AND lastName = ? AND fieldMode=?"; + . "AND lastName = ? AND fieldMode=? AND itemID=?"; if ($sortByItemCountDesc) { $sql .= " GROUP BY creatorID ORDER BY IFNULL(COUNT(*), 0) DESC"; } @@ -125,7 +125,8 @@ public static function getCreatorsWithData($libraryID, $creator, $sortByItemCoun array( $creator->firstName, $creator->lastName, - $creator->fieldMode + $creator->fieldMode, + $creator->itemID ), Zotero_Shards::getByLibraryID($libraryID) ); diff --git a/model/Item.inc.php b/model/Item.inc.php index 43cdc4d3..94a6f9e1 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); } } } @@ -907,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) { @@ -921,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; } @@ -1225,61 +1225,15 @@ public function save($userID=false) { // 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 - ); + $creator->itemID = $itemID; + $creatorsArray[] = $creator; } - if ($sqlValues) { - $sql = $sql . implode(',', $placeholders); - Zotero_DB::query($sql, $sqlValues, $shardID); - } + Zotero_Creators::bulkInsert($this->libraryID, $creatorsArray); + } @@ -1766,17 +1720,11 @@ 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(); - foreach ($indexes as $orderIndex) { Z_Core::debug('Creator in position ' . $orderIndex . ' has changed', 4); $creator = $this->getCreator($orderIndex); + // TODO: can do one update instead of delete and save() $sql2 = 'DELETE FROM itemCreators WHERE itemID=? AND orderIndex=?'; Zotero_DB::query($sql2, array($this->_id, $orderIndex), $shardID); @@ -1784,26 +1732,14 @@ public function save($userID=false) { continue; } - if ($creator['ref']->hasChanged()) { - Z_Core::debug("Auto-saving changed creator {$creator['ref']->id}"); - $creator['ref']->save(); + if ($creator->hasChanged() || !isset($creator->id)) { + $creator->itemID = $this->_id; + Z_Core::debug("Auto-saving changed creator {$creator->id}"); + $creator->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 @@ -2434,14 +2370,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"); } @@ -2468,15 +2404,15 @@ public function setCreator($orderIndex, Zotero_Creator $creator, $creatorTypeID) // 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 + && $this->creators[$orderIndex]->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->creators[$orderIndex] = $creator; + //$this->creators[$orderIndex]->creatorTypeID = $creatorTypeID; $this->changed['creators'][$orderIndex] = true; return true; } @@ -3855,12 +3791,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 @@ -3869,7 +3805,7 @@ public function toHTML(bool $asSimpleXML, $requestParams) { Zotero_Atom::addHTMLRow( $html, "creator", - Zotero_CreatorTypes::getLocalizedString($creator['creatorTypeID']), + Zotero_CreatorTypes::getLocalizedString($creator->creatorTypeID), trim($displayText) ); } @@ -4443,16 +4379,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; } @@ -4741,8 +4677,7 @@ protected function loadCreators($reload = false) { $creators = false; } if ($creators === false) { - $sql = "SELECT * FROM itemCreators - INNER JOIN creators USING (creatorID) 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); @@ -4772,12 +4707,9 @@ protected function loadCreators($reload = false) { } foreach ($creators as $creator) { - $creatorObj = new Zotero_Creator($creator['creatorID'], $this->_libraryID, $creator['firstName'], $creator['lastName'], $creator['fieldMode'] ); + $creatorObj = new Zotero_Creator($creator['creatorID'], $this->_libraryID, null, $creator['firstName'], $creator['lastName'], $creator['fieldMode'], $creator['creatorTypeID'], $creator['orderIndex']); - $this->creators[$creator['orderIndex']] = array( - 'creatorTypeID' => $creator['creatorTypeID'], - 'ref' => $creatorObj - ); + $this->creators[$creator['orderIndex']] = $creatorObj; } } diff --git a/model/Items.inc.php b/model/Items.inc.php index a8e63bda..9f78b84d 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( @@ -1732,10 +1731,10 @@ public static function updateFromJSON(Zotero_Item $item, // Same creator in this position $existingCreator = $item->getCreator($orderIndex); - if ($existingCreator && $existingCreator['ref']->equals($newCreatorData)) { + if ($existingCreator && $existingCreator->equals($newCreatorData)) { // Just change the creatorTypeID - if ($existingCreator['creatorTypeID'] != $newCreatorTypeID) { - $item->setCreator($orderIndex, $existingCreator['ref'], $newCreatorTypeID); + if ($existingCreator->creatorTypeID != $newCreatorTypeID) { + $item->setCreator($orderIndex, $existingCreator, $newCreatorTypeID); } continue; } @@ -1743,31 +1742,15 @@ public static function updateFromJSON(Zotero_Item $item, // 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); + if (isset($existingCreators[$i]) && $existingCreators[$i]->equals($newCreatorData)) { + $item->setCreator($orderIndex, $existingCreators[$i], $newCreatorTypeID); continue; } } - // Make a fake creator to use for the data lookup - $newCreator = new Zotero_Creator(null, $item->libraryID, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode, $newCreatorTypeID); - - // Look for an equivalent creator in this shard - $candidates = Zotero_Creators::getCreatorsWithData($item->libraryID, $newCreator, true); - if ($candidates) { - $item->setCreator($orderIndex, $candidates[0], $newCreatorTypeID); - continue; - } - - // None found, so prepare to make a new one - $creatorsToAdd[$orderIndex] = $newCreator; - } - // Save all new creators in bulk - if (count($creatorsToAdd) > 0) { - $addedCreators = Zotero_Creators::bulkInsert($item->libraryID, $creatorsToAdd); - foreach ($addedCreators as $order => $creator) { - $item->setCreator($order, $creator, $creator->creatorTypeID); - } + // None found, so will create a new one + $newCreator = new Zotero_Creator(null, $item->libraryID, null, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode, $newCreatorTypeID, $orderIndex); + $item->setCreator($orderIndex, $newCreator); } diff --git a/model/Libraries.inc.php b/model/Libraries.inc.php index 10535727..f3d52654 100644 --- a/model/Libraries.inc.php +++ b/model/Libraries.inc.php @@ -390,7 +390,7 @@ public static function clearAllData($libraryID) { // We fetch and delete all creators that have no items linked to them // In production, this should be a cron job if ($table == 'creators') { - $sql = "DELETE creators from creators LEFT JOIN itemCreators USING (creatorID)"; + $sql = "DELETE itemCreators from itemCreators LEFT JOIN items USING (itemID)"; $deleteCreators = Zotero_DB::query($sql, [], $shardID); continue; } From dd18ed3e709f4081a19899a9104ce488b6bcc6a6 Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Tue, 25 Jul 2023 20:58:46 +0000 Subject: [PATCH 05/13] php db migration script + use old files in autoload php cript to update the database added copies of files before changes from this PR with old_ prefix zotero_autoload checks the shard and uses old files for shards that were not migrated yet --- controllers/ApiController.php | 5 + include/header.inc.php | 10 +- .../2023-07-17/classic_data_objects.sql | 19 - .../creatorsAsNonClassicDataObjects | 46 + misc/master.sql | 1 - model/Libraries.inc.php | 10 +- model/old_Cite.inc.php | 656 +++ model/old_Creator.inc.php | 385 ++ model/old_Creators.inc.php | 206 + model/old_Item.inc.php | 5041 +++++++++++++++++ model/old_Items.inc.php | 2587 +++++++++ model/old_LIbraries.inc.php | 466 ++ 12 files changed, 9401 insertions(+), 31 deletions(-) delete mode 100644 misc/db-updates/2023-07-17/classic_data_objects.sql create mode 100755 misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects create mode 100644 model/old_Cite.inc.php create mode 100644 model/old_Creator.inc.php create mode 100644 model/old_Creators.inc.php create mode 100644 model/old_Item.inc.php create mode 100644 model/old_Items.inc.php create mode 100644 model/old_LIbraries.inc.php diff --git a/controllers/ApiController.php b/controllers/ApiController.php index b02bb4e0..932603ca 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)) { + $GLOBALS['shardID'] = Zotero_Shards::getByLibraryID($this->objectLibraryID); + } $apiVersion = !empty($_SERVER['HTTP_ZOTERO_API_VERSION']) ? (int) $_SERVER['HTTP_ZOTERO_API_VERSION'] @@ -556,6 +560,7 @@ public function testSetup() { } function getUserKey($userID) { + $GLOBALS['shardID'] = Zotero_Shards::getByUserID($userID); $keys = Zotero_Keys::getUserKeys($userID); foreach ($keys as $keyObj) { $keyObj->erase(); diff --git a/include/header.inc.php b/include/header.inc.php index 11ce7563..0d2e6dbf 100644 --- a/include/header.inc.php +++ b/include/header.inc.php @@ -42,8 +42,14 @@ function zotero_autoload($className) { else { $auth = false; } - - $path = Z_ENV_BASE_PATH . 'model/'; + $updatedShards = [1]; + $newFiles = ["Item.inc.php", "Items.inc.php", "Cite.inc.php", "Library.inc.php", "Creator.inc.php", "Creators.inc.php"]; + if (isset($GLOBALS['shardID']) && !in_array($GLOBALS['shardID'], $updatedShards) && in_array($fileName, $newFiles)) { + $path = Z_ENV_BASE_PATH . 'model/old_'; + } + else { + $path = Z_ENV_BASE_PATH . 'model/'; + } if ($auth) { $path .= 'auth/'; } diff --git a/misc/db-updates/2023-07-17/classic_data_objects.sql b/misc/db-updates/2023-07-17/classic_data_objects.sql deleted file mode 100644 index d7acffc2..00000000 --- a/misc/db-updates/2023-07-17/classic_data_objects.sql +++ /dev/null @@ -1,19 +0,0 @@ -alter table itemCreators drop constraint itemCreators_ibfk_2; -DROP table if exists creators; -DROP table if exists itemCreators; - -DROP trigger if exists fki_itemCreators_libraryID; -DROP trigger if exists fku_itemCreators_libraryID; - -CREATE TABLE `itemCreators` ( - `creatorID` bigint unsigned NOT NULL, - `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 (`creatorID`, `itemID`), - KEY `creatorTypeID` (`creatorTypeID`), - KEY `name` (`lastName`(7),`firstName`(6)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file diff --git a/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects b/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects new file mode 100755 index 00000000..560a9683 --- /dev/null +++ b/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects @@ -0,0 +1,46 @@ +#!/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); + + // 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); + + // Rename old itemCreators table + Zotero_Admin_DB::query("RENAME TABLE itemCreators TO itemCreatorsOld;", false, $shardID); + + // Create new itemCreators table + Zotero_Admin_DB::query("CREATE TABLE `itemCreators` ( `creatorID` BIGINT UNSIGNED NOT NULL, `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 (`creatorID`, `itemID`), KEY `creatorTypeID` (`creatorTypeID`), KEY `name` (`lastName`(7),`firstName`(6)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;", false, $shardID); + + // Add foreign key to item constraint + Zotero_Admin_DB::query("ALTER TABLE `itemCreators` 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 itemCreators (creatorID, firstName, lastName, fieldMode, itemID, creatorTypeID, orderIndex ) SELECT creatorID, firstName, lastName, fieldMode, itemID, creatorTypeID, orderIndex from creators INNER JOIN itemCreatorsOld USING (creatorID);", false, $shardID); + + // Drop old creators tables + Zotero_Admin_DB::query("DROP TABLE itemCreatorsOld;", false, $shardID); + Zotero_Admin_DB::query("DROP TABLE creators;", 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/master.sql b/misc/master.sql index e33002d5..4c79cd11 100644 --- a/misc/master.sql +++ b/misc/master.sql @@ -184,7 +184,6 @@ CREATE TABLE `libraries` ( `lastUpdated` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', `version` int(10) unsigned NOT NULL DEFAULT '0', `shardID` smallint(5) unsigned NOT NULL, - `hasData` TINYINT( 1 ) NOT NULL DEFAULT '0', PRIMARY KEY (`libraryID`), KEY `shardID` (`shardID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/model/Libraries.inc.php b/model/Libraries.inc.php index f3d52654..92262219 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', 'items', 'creators', 'relations', 'savedSearches', 'tags', + 'collections', 'items', 'relations', 'savedSearches', 'tags', 'syncDeleteLogIDs', 'syncDeleteLogKeys', 'settings' ); @@ -386,14 +386,6 @@ public static function clearAllData($libraryID) { Zotero_FullText::deleteByLibraryMySQL($libraryID); foreach ($tables as $table) { - // Creators deleted after items - // We fetch and delete all creators that have no items linked to them - // In production, this should be a cron job - if ($table == 'creators') { - $sql = "DELETE itemCreators from itemCreators LEFT JOIN items USING (itemID)"; - $deleteCreators = Zotero_DB::query($sql, [], $shardID); - continue; - } // For items, delete annotations first, then notes and attachments, then items after if ($table == 'items') { $itemTypeIDs = Zotero_DB::columnQuery( 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_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

From 422ac137f279069ad1a3ba6e4eda3680707d8d5d Mon Sep 17 00:00:00 2001
From: Bogdan Abaev <bogdan@zotero.org>
Date: Tue, 25 Jul 2023 21:20:32 +0000
Subject: [PATCH 06/13] removed typo from header

---
 include/header.inc.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/include/header.inc.php b/include/header.inc.php
index 0d2e6dbf..8773c78a 100644
--- a/include/header.inc.php
+++ b/include/header.inc.php
@@ -42,7 +42,7 @@ function zotero_autoload($className) {
 		else {
 			$auth = false;
 		}
-		$updatedShards = [1];
+		$updatedShards = [];
 		$newFiles = ["Item.inc.php", "Items.inc.php", "Cite.inc.php", "Library.inc.php", "Creator.inc.php", "Creators.inc.php"];
 		if (isset($GLOBALS['shardID']) && !in_array($GLOBALS['shardID'], $updatedShards) && in_array($fileName, $newFiles)) {
 			$path = Z_ENV_BASE_PATH . 'model/old_';

From baf5c9341313a74d40fb9846400261bbe97e8380 Mon Sep 17 00:00:00 2001
From: Bogdan Abaev <bogdan@zotero.org>
Date: Thu, 27 Jul 2023 00:43:04 +0000
Subject: [PATCH 07/13] removing unused code, prevent updating unchanged
 creators

---
 model/Creators.inc.php | 62 ------------------------------------------
 model/Item.inc.php     |  9 +++---
 model/Items.inc.php    | 21 +-------------
 3 files changed, 6 insertions(+), 86 deletions(-)

diff --git a/model/Creators.inc.php b/model/Creators.inc.php
index dd27e395..6020644d 100644
--- a/model/Creators.inc.php
+++ b/model/Creators.inc.php
@@ -86,68 +86,6 @@ public static function bulkInsert($libraryID, $orderedCreators) {
 		Zotero_DB::queryFromStatement($stmt, $paramList);
 		return $orderedCreators;
 	}
-
-	public static function get($libraryID, $creatorID) {
-		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];
-		}
-		
-		$sql = 'SELECT * FROM itemCreators WHERE creatorID=?';
-		$creator = Zotero_DB::rowQuery($sql, $creatorID, Zotero_Shards::getByLibraryID($libraryID));
-		if (!$creator) {
-			return false;
-		}
-		
-		$creator = new Zotero_Creator($creator['creatorID'], $libraryID, $creator['firstName'], $creator['lastName'], $creator['fieldMode'] );
-		
-		self::$creatorsByID[$creatorID] = $creator;
-		return self::$creatorsByID[$creatorID];
-	}
-	
-	
-	public static function getCreatorsWithData($libraryID, $creator, $sortByItemCountDesc=false) {
-		$sql = "SELECT creatorID, firstName, lastName, fieldMode FROM itemCreators ";
-		$sql .= "WHERE firstName = ? "
-			. "AND lastName = ? AND fieldMode=? AND itemID=?";
-		if ($sortByItemCountDesc) {
-			$sql .= " GROUP BY creatorID ORDER BY IFNULL(COUNT(*), 0) DESC";
-		}
-		$rows = Zotero_DB::query(
-			$sql,
-			array(
-				$creator->firstName,
-				$creator->lastName,
-				$creator->fieldMode,
-				$creator->itemID
-			),
-			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;
-		});
-
-		$result = [];
-		foreach($rows as $row) {
-			$c = new Zotero_Creator($row['creatorID'], $libraryID, $row['firstName'], $row['lastName'], $row['fieldMode'] ); 
-			if (empty(self::$creatorsByID[$row['creatorID']])) {
-				self::$creatorsByID[$row['creatorID']] = $c;
-			}
-			array_push($result, $c);
-		}
-		
-		return $result;
-	}
 	
 	
 /*
diff --git a/model/Item.inc.php b/model/Item.inc.php
index 94a6f9e1..07cf0c06 100644
--- a/model/Item.inc.php
+++ b/model/Item.inc.php
@@ -2410,10 +2410,11 @@ public function setCreator($orderIndex, Zotero_Creator $creator) {
 			Z_Core::debug("Creator in position $orderIndex hasn't changed", 4);
 			return false;
 		}
-		
-		$this->creators[$orderIndex] = $creator;
-		//$this->creators[$orderIndex]->creatorTypeID = $creatorTypeID;
-		$this->changed['creators'][$orderIndex] = true;
+		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;
 	}
 	
diff --git a/model/Items.inc.php b/model/Items.inc.php
index 9f78b84d..a42ba0d3 100644
--- a/model/Items.inc.php
+++ b/model/Items.inc.php
@@ -1729,26 +1729,7 @@ public static function updateFromJSON(Zotero_Item $item,
 						
 						$newCreatorTypeID = Zotero_CreatorTypes::getID($newCreatorData->creatorType);
 						
-						// Same creator in this position
-						$existingCreator = $item->getCreator($orderIndex);
-						if ($existingCreator && $existingCreator->equals($newCreatorData)) {
-							// Just change the creatorTypeID
-							if ($existingCreator->creatorTypeID != $newCreatorTypeID) {
-								$item->setCreator($orderIndex, $existingCreator, $newCreatorTypeID);
-							}
-							continue;
-						}
-						
-						// Same creator in a different position, so use that
-						$existingCreators = $item->getCreators();
-						for ($i=0,$len=sizeOf($existingCreators); $i<$len; $i++) {
-							if (isset($existingCreators[$i]) && $existingCreators[$i]->equals($newCreatorData)) {
-								$item->setCreator($orderIndex, $existingCreators[$i], $newCreatorTypeID);
-								continue;
-							}
-						}
-						
-						// None found, so will create a new one 
+						// Make creator object
 						$newCreator = new Zotero_Creator(null, $item->libraryID, null, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode, $newCreatorTypeID, $orderIndex);
 						$item->setCreator($orderIndex, $newCreator);
 					}

From 8a25c08dd22cce7e7554603dfc72ea10a9790565 Mon Sep 17 00:00:00 2001
From: Bogdan Abaev <bogdan@zotero.org>
Date: Fri, 28 Jul 2023 00:05:30 +0000
Subject: [PATCH 08/13] removing tags as classic data objects - in progress

---
 controllers/TagsController.php                |   8 +-
 .../creatorsAsNonClassicDataObjects           |  39 ++-
 model/Creators.inc.php                        |   7 -
 model/Item.inc.php                            |  85 ++-----
 model/Items.inc.php                           |  19 +-
 model/Libraries.inc.php                       |   2 +-
 model/Tag.inc.php                             | 222 +++---------------
 model/Tags.inc.php                            | 114 +++++++--
 8 files changed, 183 insertions(+), 313 deletions(-)

diff --git a/controllers/TagsController.php b/controllers/TagsController.php
index dad6a482..46237fc3 100644
--- a/controllers/TagsController.php
+++ b/controllers/TagsController.php
@@ -187,13 +187,11 @@ public function tags() {
 				$tagNames = !empty($this->queryParams['tag'])
 					? explode(' || ', $this->queryParams['tag']): array();
 				Zotero_DB::beginTransaction();
+				$tagIDs = [];
 				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);
-					}
+					$tagIDs[] = Zotero_Tags::getIDs($this->objectLibraryID, $tagName);
 				}
+				Zotero_Tags::bulkDelete($this->objectLibraryID, $tagIDs);
 				Zotero_DB::commit();
 				$this->e204();
 			}
diff --git a/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects b/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects
index 560a9683..95be90cb 100755
--- a/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects
+++ b/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects
@@ -19,26 +19,51 @@ foreach ($shardIDs as $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);
 
-	// Rename old itemCreators table
-	Zotero_Admin_DB::query("RENAME TABLE itemCreators TO itemCreatorsOld;", false, $shardID);
-
 	// Create new itemCreators table
-	Zotero_Admin_DB::query("CREATE TABLE `itemCreators` ( `creatorID` BIGINT UNSIGNED NOT NULL, `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 (`creatorID`, `itemID`), KEY `creatorTypeID` (`creatorTypeID`), KEY `name` (`lastName`(7),`firstName`(6)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;", false, $shardID);
+	Zotero_Admin_DB::query("CREATE TABLE `itemCreatorsNew` ( `creatorID` BIGINT UNSIGNED NOT NULL, `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 (`creatorID`, `itemID`), KEY `creatorTypeID` (`creatorTypeID`), KEY `name` (`lastName`(7),`firstName`(6)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;", false, $shardID);
 
 	// Add foreign key to item constraint
-	Zotero_Admin_DB::query("ALTER TABLE `itemCreators` ADD CONSTRAINT `itemCreators_ibfk_1` FOREIGN KEY (`itemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE;", false, $shardID);
+	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 itemCreators (creatorID, firstName, lastName, fieldMode, itemID, creatorTypeID, orderIndex ) SELECT creatorID, firstName, lastName, fieldMode, itemID, creatorTypeID, orderIndex from creators INNER JOIN itemCreatorsOld USING (creatorID);", false, $shardID);
+	Zotero_Admin_DB::query("INSERT INTO itemCreatorsNew (creatorID, firstName, lastName, fieldMode, itemID, creatorTypeID, orderIndex ) SELECT creatorID, 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 itemCreatorsOld;", false, $shardID);
+	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`) ) 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";
diff --git a/model/Creators.inc.php b/model/Creators.inc.php
index 6020644d..db4c5aa9 100644
--- a/model/Creators.inc.php
+++ b/model/Creators.inc.php
@@ -29,13 +29,6 @@ class Zotero_Creators {
 	
 	protected static $ZDO_object = 'creator';
 	
-	protected static $primaryFields = array(
-		'id' => 'creatorID',
-		'libraryID' => '',
-		'firstName' => '',
-		'lastName' => '',
-		'fieldMode' => ''
-	);
 	private static $fields = array(
 		'firstName', 'lastName', 'fieldMode'
 	);
diff --git a/model/Item.inc.php b/model/Item.inc.php
index 07cf0c06..bb7cedc1 100644
--- a/model/Item.inc.php
+++ b/model/Item.inc.php
@@ -1527,21 +1527,10 @@ 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();
+						$tag->itemID = $itemID;
 					}
+					Zotero_Tags::bulkInsert($this->libraryID, $this->tags);
 				}
  				
 				// Related items
@@ -2174,27 +2163,10 @@ public function save($userID=false) {
 					$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();
+						$tag->itemID = $this->_id;
 					}
+					Zotero_Tags::bulkInsert($this->_libraryID, $toAdd);
+					Zotero_Tags::bulkDelete($this->_libraryID, $toRemove);
 				}
 				
 				// Related items
@@ -3646,28 +3618,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;
 	}
 	
 	
@@ -3696,17 +3651,17 @@ public function setTags($newTags) {
 		$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;
+				$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;
 			}
-			$this->tags[] = $obj;
+			
+			$this->tags[] = new Zotero_Tag(null, $this->libraryID, null, $name, $type, 0);
 		}
 		$this->changed['tags'] = true;
 	}
@@ -4747,14 +4702,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['itemID'], $tag['name'], $tag['type'], $tag['version']);
 			}
 		}
 		$this->loaded['tags'] = true;
diff --git a/model/Items.inc.php b/model/Items.inc.php
index a42ba0d3..4e92a5bc 100644
--- a/model/Items.inc.php
+++ b/model/Items.inc.php
@@ -418,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);
 				
 				$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 itemTags.name IN (" . implode(',', array_fill(0, sizeOf($set['values']), '?')) . ")";
+				$ids = Zotero_DB::columnQuery($tmpSQL, $set['values'], $shardID);
 				
 				if (!$ids) {
 					// If no negative tags, skip this tag set
@@ -446,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);
 			}
 			
@@ -1728,7 +1717,7 @@ public static function updateFromJSON(Zotero_Item $item,
 						$orderIndex++;
 						
 						$newCreatorTypeID = Zotero_CreatorTypes::getID($newCreatorData->creatorType);
-						
+
 						// Make creator object
 						$newCreator = new Zotero_Creator(null, $item->libraryID, null, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode, $newCreatorTypeID, $orderIndex);
 						$item->setCreator($orderIndex, $newCreator);
diff --git a/model/Libraries.inc.php b/model/Libraries.inc.php
index 92262219..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', '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..311a5776 100644
--- a/model/Tag.inc.php
+++ b/model/Tag.inc.php
@@ -27,33 +27,25 @@
 class Zotero_Tag {
 	private $id;
 	private $libraryID;
-	private $key;
+	private $itemID;
 	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;
-		
+	public function __construct($id, $libraryID, $itemID, $name, $type, $version) {
+		$this->id = $id;
+		$this->libraryID = $libraryID;
+		$this->itemID = $itemID;
+		$this->name = $name;
+		$this->type = $type;
+		$this->version = $version;
+
 		$this->previousData = array();
 		$this->linkedItemsLoaded = false;
 		
@@ -61,8 +53,6 @@ private function init() {
 		$props = array(
 			'name',
 			'type',
-			'dateAdded',
-			'dateModified',
 			'linkedItems'
 		);
 		foreach ($props as $prop) {
@@ -70,11 +60,7 @@ 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");
@@ -88,23 +74,12 @@ 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");
-				}
+			case 'itemID':
 				$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);
 		
@@ -115,19 +90,6 @@ public function __set($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) {
@@ -159,11 +121,11 @@ public function removeItem($key) {
 	
 	
 	public function hasChanged() {
-		// Exclude 'dateModified' from test
+		// Exclude 'dateg' from test
 		$changed = $this->changed;
-		if (!empty($changed['dateModified'])) {
-			unset($changed['dateModified']);
-		}
+		// if (!empty($changed['dateModified'])) {
+		// 	unset($changed['dateModified']);
+		// }
 		return in_array(true, array_values($changed));
 	}
 	
@@ -193,21 +155,15 @@ public function save($userID=false, $full=false) {
 			$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=?";
+			$fields = "name=?, itemID=?, `type`=?, version=?";
 			$params = array(
 				$this->name,
+				$this->itemID,
 				$this->type ? $this->type : 0,
-				$dateAdded,
-				$dateModified,
-				$this->libraryID,
-				$key,
-				$timestamp,
 				$version
 			);
 			
@@ -348,18 +304,6 @@ public function save($userID=false, $full=false) {
 			
 			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();
@@ -444,12 +388,7 @@ public function setLinkedItems($newKeys) {
 	
 	public function serialize() {
 		$obj = array(
-			'primary' => array(
-				'tagID' => $this->id,
-				'dateAdded' => $this->dateAdded,
-				'dateModified' => $this->dateModified,
-				'key' => $this->key
-			),
+			'tagID' => $this->id,
 			'name' => $this->name,
 			'type' => $this->type,
 			'linkedItems' => $this->getLinkedItems(true),
@@ -460,9 +399,6 @@ public function serialize() {
 	
 	
 	public function toResponseJSON() {
-		if (!$this->loaded) {
-			$this->load();
-		}
 		
 		$json = [
 			'tag' => $this->name
@@ -497,10 +433,6 @@ public function toResponseJSON() {
 	
 	
 	public function toJSON() {
-		if (!$this->loaded) {
-			$this->load();
-		}
-		
 		$arr['tag'] = $this->name;
 		$arr['type'] = $this->type;
 		
@@ -538,7 +470,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";
@@ -584,107 +515,25 @@ 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();
-		}
+		// if (!$this->id) {
+		// 	$this->linkedItemsLoaded = true;
+		// 	return;
+		// }
 		
-		if (!$this->id) {
-			$this->linkedItemsLoaded = true;
-			return;
-		}
+		// if (!$this->id) {
+		// 	$this->linkedItemsLoaded = true;
+		// 	return;
+		// }
 		
-		$sql = "SELECT `key` FROM itemTags JOIN items USING (itemID) WHERE tagID=?";
+		$sql = "SELECT itemID FROM itemTags WHERE name=?";
 		$stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
-		$keys = Zotero_DB::columnQueryFromStatement($stmt, $this->id);
+		$itemIds = Zotero_DB::columnQueryFromStatement($stmt, $this->name);
 		
-		$this->linkedItems = $keys ? $keys : array();
+		$this->linkedItems = $itemIds ? $itemIds : array();
 		$this->linkedItemsLoaded = true;
 	}
 	
@@ -698,25 +547,12 @@ private function checkValue($field, $value) {
 		switch ($field) {
 			case 'id':
 			case 'libraryID':
+			case 'itemID':
 				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);
diff --git a/model/Tags.inc.php b/model/Tags.inc.php
index 2539a2ad..2625f898 100644
--- a/model/Tags.inc.php
+++ b/model/Tags.inc.php
@@ -24,25 +24,102 @@
     ***** 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();
 	
+	public static function bulkDelete($libraryID, $tags) {
+		if (sizeof($tags) == 0){
+			return;
+		}
+		$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 
+			 ]);
+		}
+		$placeholdersStr = implode(", ", $placeholdersArray);
+		$sql = "DELETE FROM itemTags WHERE tagID in ($placeholdersStr)";
+
+		$stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($libraryID));
+		Zotero_DB::queryFromStatement($stmt, $paramList);
+		return $tags;
+	}
+
+
+	public static function bulkInsert($libraryID, $tags) {
+		$placeholdersArray = array();
+		$paramList = array();
+		foreach ($tags as $tag) {
+			if (isset($tag->id)) {
+				throw new Exception("Insert not possible for tag with a set tagID");
+			}
+			$tag->id = Zotero_ID::get('tags');
+			$placeholdersArray[] = "(?, ?, ?, ?, ?)";
+			$paramList = array_merge($paramList, [
+				$tag->id,
+				$tag->itemID,
+				$tag->name,
+				$tag->type,
+				$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) {
+		$placeholders = implode(',', array_fill(0, sizeOf($tagIDs), '?'));
+
+		$sql = "SELECT name, count(*) as count FROM itemTags WHERE tagID in ($placeholders) GROUP BY 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(null, $libraryID, null, $tag['name'], null, null);
+		}
+		
+		return $tagObjects;
+	}
+
+	public static function delete($libraryID, $tagIDs) {
+		$placeholders = implode(',', array_fill(0, sizeOf($tagIDs), '?'));
+
+		$sql = "SELECT name, count(*) as count FROM itemTags WHERE tagID in ($placeholders) GROUP BY 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(null, $libraryID, null, $tag['name'], null, null);
+		}
+		
+		return $tagObjects;
+	}
+
 	/*
 	 * Returns a tag and type for a given tagID
 	 */
@@ -89,7 +166,7 @@ public static function getID($libraryID, $name, $type, $caseInsensitive=false) {
 		
 		// TODO: cache
 		
-		$sql = "SELECT tagID FROM tags WHERE ";
+		$sql = "SELECT tagID FROM itemTags WHERE ";
 		if ($caseInsensitive) {
 			$sql .= "LOWER(name)=?";
 			$params = [strtolower($name)];
@@ -98,7 +175,7 @@ public static function getID($libraryID, $name, $type, $caseInsensitive=false) {
 			$sql .= "name=?";
 			$params = [$name];
 		}
-		$sql .= " AND type=? AND libraryID=?";
+		$sql .= " AND type=?";
 		array_push($params, $type, $libraryID);
 		$tagID = Zotero_DB::valueQuery($sql, $params, Zotero_Shards::getByLibraryID($libraryID));
 		
@@ -113,7 +190,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 tagID FROM itemTags JOIN items USING (itemID) WHERE libraryID = ? AND name";
 		if ($caseInsensitive) {
 			$sql .= " COLLATE utf8mb4_unicode_ci ";
 		}
@@ -136,8 +213,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
@@ -248,10 +325,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;
 		}
 		

From ed68a2eaa0b0ba9331136e8c58eff98179fe9709 Mon Sep 17 00:00:00 2001
From: Bogdan Abaev <bogdan@zotero.org>
Date: Fri, 28 Jul 2023 23:08:41 +0000
Subject: [PATCH 09/13] tag tests passing, move old model files

---
 controllers/ItemsController.php |  24 +-
 controllers/TagsController.php  |  21 +-
 include/header.inc.php          |  15 +-
 model/Collection.inc.php        |  10 +-
 model/Item.inc.php              |  32 +-
 model/Items.inc.php             |   6 +-
 model/Tag.inc.php               | 369 ++++++-------
 model/Tags.inc.php              | 120 ++---
 model/old_Collection.inc.php    | 910 ++++++++++++++++++++++++++++++++
 model/old_Tag.inc.php           | 744 ++++++++++++++++++++++++++
 model/old_Tags.inc.php          | 269 ++++++++++
 11 files changed, 2223 insertions(+), 297 deletions(-)
 create mode 100644 model/old_Collection.inc.php
 create mode 100644 model/old_Tag.inc.php
 create mode 100644 model/old_Tags.inc.php

diff --git a/controllers/ItemsController.php b/controllers/ItemsController.php
index d3a6e489..df3fc1da 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 (in_array($GLOBALS['shardID'], $GLOBALS['updatedShards']) ) {
+							$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 46237fc3..b273ec8b 100644
--- a/controllers/TagsController.php
+++ b/controllers/TagsController.php
@@ -187,11 +187,24 @@ public function tags() {
 				$tagNames = !empty($this->queryParams['tag'])
 					? explode(' || ', $this->queryParams['tag']): array();
 				Zotero_DB::beginTransaction();
-				$tagIDs = [];
-				foreach ($tagNames as $tagName) {
-					$tagIDs[] = Zotero_Tags::getIDs($this->objectLibraryID, $tagName);
+				// Different delete behavior depending on if we are on migrated shard or not
+				// because after migration $tag->key does not exist
+				if (in_array($GLOBALS['shardID'], $GLOBALS['updatedShards']) ) {
+					$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_Tags::bulkDelete($this->objectLibraryID, $tagIDs);
 				Zotero_DB::commit();
 				$this->e204();
 			}
diff --git a/include/header.inc.php b/include/header.inc.php
index 8773c78a..206f524f 100644
--- a/include/header.inc.php
+++ b/include/header.inc.php
@@ -42,8 +42,17 @@ function zotero_autoload($className) {
 		else {
 			$auth = false;
 		}
-		$updatedShards = [];
-		$newFiles = ["Item.inc.php", "Items.inc.php", "Cite.inc.php", "Library.inc.php", "Creator.inc.php", "Creators.inc.php"];
+		$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 (isset($GLOBALS['shardID']) && !in_array($GLOBALS['shardID'], $updatedShards) && in_array($fileName, $newFiles)) {
 			$path = Z_ENV_BASE_PATH . 'model/old_';
 		}
@@ -80,7 +89,7 @@ function zotero_autoload($className) {
 		return;
 	}
 }
-
+$GLOBALS['updatedShards'] = [1];
 spl_autoload_register('zotero_autoload');
 
 // Read in configuration variables
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/Item.inc.php b/model/Item.inc.php
index bb7cedc1..233790f5 100644
--- a/model/Item.inc.php
+++ b/model/Item.inc.php
@@ -2166,7 +2166,7 @@ public function save($userID=false) {
 						$tag->itemID = $this->_id;
 					}
 					Zotero_Tags::bulkInsert($this->_libraryID, $toAdd);
-					Zotero_Tags::bulkDelete($this->_libraryID, $toRemove);
+					Zotero_Tags::bulkDelete($this->_libraryID, $this->_id, $toRemove);
 				}
 				
 				// Related items
@@ -3649,7 +3649,11 @@ public function setTags($newTags) {
 		}
 		
 		$this->storePreviousData('tags');
-		$this->tags = [];
+
+		$existingTagNames = array_map(function($tag) {
+			return $tag->name;
+		}, $this->tags);
+		$foundNames = [];
 		foreach ($newTags as $newTag) {
 			// Allow the passed array to contain either strings or objects
 			if (is_string($newTag)) {
@@ -3660,10 +3664,28 @@ public function setTags($newTags) {
 				$name = trim($newTag->tag);
 				$type = (int) isset($newTag->type) ? $newTag->type : 0;
 			}
-			
-			$this->tags[] = new Zotero_Tag(null, $this->libraryID, null, $name, $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;
+			}
+			$version = Zotero_Libraries::getUpdatedVersion($this->libraryID);
+			$this->tags[] = new Zotero_Tag(null, $this->libraryID, null, $name, $type, $version);
+			$this->changed['tags'] = true;
+		}
+		$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;
 	}
 	
 	
diff --git a/model/Items.inc.php b/model/Items.inc.php
index 4e92a5bc..148b5943 100644
--- a/model/Items.inc.php
+++ b/model/Items.inc.php
@@ -418,10 +418,10 @@ public static function search($libraryID, $onlyTopLevel = false, array $params =
 			$negatives = array();
 			
 			foreach ($tagSets as $set) {
-				
+				$lowercaseTags = array_map('strtolower', $set['values']);
 				$tmpSQL = "SELECT itemID FROM items JOIN itemTags USING (itemID) "
-						. "WHERE itemTags.name IN (" . implode(',', array_fill(0, sizeOf($set['values']), '?')) . ")";
-				$ids = Zotero_DB::columnQuery($tmpSQL, $set['values'], $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
diff --git a/model/Tag.inc.php b/model/Tag.inc.php
index 311a5776..db7fc6da 100644
--- a/model/Tag.inc.php
+++ b/model/Tag.inc.php
@@ -39,12 +39,12 @@ class Zotero_Tag {
 	private $linkedItems = array();
 	
 	public function __construct($id, $libraryID, $itemID, $name, $type, $version) {
-		$this->id = $id;
-		$this->libraryID = $libraryID;
-		$this->itemID = $itemID;
-		$this->name = $name;
-		$this->type = $type;
-		$this->version = $version;
+		$this->__set("id", $id);
+		$this->__set("libraryID", $libraryID);
+		$this->__set("itemID", $itemID);
+		$this->__set("name", $name);
+		$this->__set("type", $type);
+		$this->__set("version", $version);		
 
 		$this->previousData = array();
 		$this->linkedItemsLoaded = false;
@@ -63,7 +63,8 @@ public function __construct($id, $libraryID, $itemID, $name, $type, $version) {
 	public function __get($field) {
 		
 		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;
@@ -83,7 +84,7 @@ public function __set($field, $value) {
 		
 		$this->checkValue($field, $value);
 		
-		if ($this->$field != $value) {
+		if ($this->$field !== $value) {
 			$this->prepFieldChange($field);
 			$this->$field = $value;
 		}
@@ -130,202 +131,202 @@ public function hasChanged() {
 	}
 	
 	
-	public function save($userID=false, $full=false) {
-		if (!$this->libraryID) {
-			trigger_error("Library ID must be set before saving", E_USER_ERROR);
-		}
+	// 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);
+	// 	Zotero_Tags::editCheck($this, $userID);
 		
-		if (!$this->hasChanged()) {
-			Z_Core::debug("Tag $this->id has not changed");
-			return false;
-		}
+	// 	if (!$this->hasChanged()) {
+	// 		Z_Core::debug("Tag $this->id has not changed");
+	// 		return false;
+	// 	}
 		
-		$shardID = Zotero_Shards::getByLibraryID($this->libraryID);
+	// 	$shardID = Zotero_Shards::getByLibraryID($this->libraryID);
 		
-		Zotero_DB::beginTransaction();
+	// 	Zotero_DB::beginTransaction();
 		
-		try {
-			$tagID = $this->id ? $this->id : Zotero_ID::get('tags');
-			$isNew = !$this->id;
+	// 	try {
+	// 		$tagID = $this->id ? $this->id : Zotero_ID::get('tags');
+	// 		$isNew = !$this->id;
 			
-			Z_Core::debug("Saving tag $tagID");
+	// 		Z_Core::debug("Saving tag $tagID");
 			
-			$key = $this->key ? $this->key : Zotero_ID::getKey();
-			$timestamp = Zotero_DB::getTransactionTimestamp();
-			$dateAdded = $this->dateAdded ? $this->dateAdded : $timestamp;
-			$version = ($this->changed['name'] || $this->changed['type'])
-				? Zotero_Libraries::getUpdatedVersion($this->libraryID)
-				: $this->version;
+	// 		$key = $this->key ? $this->key : Zotero_ID::getKey();
+	// 		$timestamp = Zotero_DB::getTransactionTimestamp();
+	// 		$dateAdded = $this->dateAdded ? $this->dateAdded : $timestamp;
+	// 		$version = ($this->changed['name'] || $this->changed['type'])
+	// 			? Zotero_Libraries::getUpdatedVersion($this->libraryID)
+	// 			: $this->version;
 			
-			$fields = "name=?, itemID=?, `type`=?, version=?";
-			$params = array(
-				$this->name,
-				$this->itemID,
-				$this->type ? $this->type : 0,
-				$version
-			);
+	// 		$fields = "name=?, itemID=?, `type`=?, version=?";
+	// 		$params = array(
+	// 			$this->name,
+	// 			$this->itemID,
+	// 			$this->type ? $this->type : 0,
+	// 			$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));
+	// 		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;
-						}
-					}
+	// 				// 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));
+	// 				// 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
-						);
+	// 					// 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)));
-					}
+	// 				}
+	// 				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;
-				}
-			}
+	// 				$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);
+	// 		// 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 ($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 ($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
-					);
-				}
+	// 			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.Notifier.trigger('add', 'collection-item', $this->id . '-' . $itemID);
+	// 		}
 			
-			Zotero_DB::commit();
+	// 		Zotero_DB::commit();
 			
-		}
-		catch (Exception $e) {
-			Zotero_DB::rollback();
-			throw ($e);
-		}
+	// 	}
+	// 	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;
-		}
+	// 	// If successful, set values in object
+	// 	if (!$this->id) {
+	// 		$this->id = $tagID;
+	// 	}
+	// 	if (!$this->key) {
+	// 		$this->key = $key;
+	// 	}
 		
-		$this->init();
+	// 	$this->init();
 		
-		if ($isNew) {
-			Zotero_Tags::cache($this);
-		}
+	// 	if ($isNew) {
+	// 		Zotero_Tags::cache($this);
+	// 	}
 		
-		return $this->id;
-	}
+	// 	return $this->id;
+	// }
 	
 	
 	public function getLinkedItems($asKeys=false) {
@@ -529,9 +530,9 @@ private function loadLinkedItems() {
 		// 	return;
 		// }
 		
-		$sql = "SELECT itemID FROM itemTags WHERE name=?";
+		$sql = "SELECT itemID FROM itemTags JOIN items USING (itemID) WHERE name=? AND libraryID=?";
 		$stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
-		$itemIds = Zotero_DB::columnQueryFromStatement($stmt, $this->name);
+		$itemIds = Zotero_DB::columnQueryFromStatement($stmt, [$this->name, $this->libraryID]);
 		
 		$this->linkedItems = $itemIds ? $itemIds : array();
 		$this->linkedItemsLoaded = true;
@@ -542,7 +543,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':
@@ -567,7 +570,7 @@ private function prepFieldChange($field) {
 		
 		// Save a copy of the data before changing
 		// TODO: only save previous data if tag exists
-		if ($this->id && $this->exists() && !$this->previousData) {
+		if ($this->id && !$this->previousData) {
 			$this->previousData = $this->serialize();
 		}
 	}
diff --git a/model/Tags.inc.php b/model/Tags.inc.php
index 2625f898..8b6906a1 100644
--- a/model/Tags.inc.php
+++ b/model/Tags.inc.php
@@ -33,7 +33,7 @@ class Zotero_Tags {
 	private static $tagsByID = array();
 	private static $namesByHash = array();
 	
-	public static function bulkDelete($libraryID, $tags) {
+	public static function bulkDelete($libraryID, $itemID, $tags) {
 		if (sizeof($tags) == 0){
 			return;
 		}
@@ -57,31 +57,54 @@ public static function bulkDelete($libraryID, $tags) {
 			 ]);
 		}
 		$placeholdersStr = implode(", ", $placeholdersArray);
-		$sql = "DELETE FROM itemTags WHERE tagID in ($placeholdersStr)";
 
+		$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);
+		}
+
+		$sql = "DELETE FROM itemTags WHERE tagID in ($placeholdersStr)";
+		if (isset($itemID)) {
+			$sql .= " AND itemID=?";
+			$paramList = array_merge($paramList, [$itemID]);
+		}
 		$stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($libraryID));
 		Zotero_DB::queryFromStatement($stmt, $paramList);
+
 		return $tags;
 	}
 
 
 	public static function bulkInsert($libraryID, $tags) {
+		if (sizeof($tags) == 0){
+			return;
+		}
 		$placeholdersArray = array();
 		$paramList = array();
+		$itemIDs = [];
 		foreach ($tags as $tag) {
 			if (isset($tag->id)) {
 				throw new Exception("Insert not possible for tag with a set tagID");
 			}
-			$tag->id = Zotero_ID::get('tags');
+			$existingTagsSql = "SELECT t.tagID, t.version from itemTags t JOIN items i USING (itemID) WHERE name = ? AND libraryID = ? ORDER BY version LIMIT 1;"; 
+	
+			$existinTagData = Zotero_DB::query($existingTagsSql, [$tag->name, $libraryID], Zotero_Shards::getByLibraryID($libraryID));
+	
+			$itemIDs[] = $tag->itemID;
+			$tag->id = sizeof($existinTagData) > 0 ? $existinTagData[0]['tagID'] : Zotero_ID::get('tags');
 			$placeholdersArray[] = "(?, ?, ?, ?, ?)";
 			$paramList = array_merge($paramList, [
 				$tag->id,
 				$tag->itemID,
 				$tag->name,
 				$tag->type,
-				$tag->version,
+				sizeof($existinTagData) > 0 ? $existinTagData[0]['version'] : $tag->version,
 			 ]);
 		}
+
 		$placeholdersStr = implode(", ", $placeholdersArray);
 		$sql = "INSERT INTO itemTags (tagID, itemID, name, type, version) VALUES $placeholdersStr";
 
@@ -91,95 +114,28 @@ public static function bulkInsert($libraryID, $tags) {
 	}
 
 	public static function bulkGet($libraryID, $tagIDs) {
-		$placeholders = implode(',', array_fill(0, sizeOf($tagIDs), '?'));
-
-		$sql = "SELECT name, count(*) as count FROM itemTags WHERE tagID in ($placeholders) GROUP BY 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(null, $libraryID, null, $tag['name'], null, null);
+		if (sizeof($tagIDs) == 0){
+			return []; 
 		}
-		
-		return $tagObjects;
-	}
-
-	public static function delete($libraryID, $tagIDs) {
 		$placeholders = implode(',', array_fill(0, sizeOf($tagIDs), '?'));
 
-		$sql = "SELECT name, count(*) as count FROM itemTags WHERE tagID in ($placeholders) GROUP BY name";
+		$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(null, $libraryID, null, $tag['name'], null, null);
+			$tagObjects[] = new Zotero_Tag($tag['tagID'], $libraryID, null, $tag['name'], $tag['type'], null);
 		}
 		
 		return $tagObjects;
 	}
 
-	/*
-	 * 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 itemTags WHERE ";
-		if ($caseInsensitive) {
-			$sql .= "LOWER(name)=?";
-			$params = [strtolower($name)];
-		}
-		else {
-			$sql .= "name=?";
-			$params = [$name];
-		}
-		$sql .= " AND type=?";
-		array_push($params, $type, $libraryID);
-		$tagID = Zotero_DB::valueQuery($sql, $params, Zotero_Shards::getByLibraryID($libraryID));
-		
-		return $tagID;
+	public static function loadLinkedItemsKeys($libraryID, $tagID) {
+		$sql = "SELECT `key` FROM itemTags JOIN items USING (itemID) WHERE tagID=? AND libraryID=?";
+		$stmt = Zotero_DB::getStatement($sql, true, $libraryID);
+		$itemKeys = Zotero_DB::columnQueryFromStatement($stmt, [$tagID, $libraryID]);
+		return $itemKeys;
 	}
 	
 	
@@ -190,7 +146,7 @@ public static function getIDs($libraryID, $name, $caseInsensitive=false) {
 		// Default empty library
 		if ($libraryID === 0) return [];
 		
-		$sql = "SELECT tagID FROM itemTags JOIN items USING (itemID) 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 ";
 		}
@@ -294,7 +250,7 @@ public static function search($libraryID, $params) {
 		}
 		
 		if (!empty($params['since'])) {
-			$sql .= "AND version > ? ";
+			$sql .= "AND itemTags.version > ? ";
 			$sqlParams[] = $params['since'];
 		}
 		
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 @@
+<?
+/*
+    ***** 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_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_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

From ea9030c888c12f88862cb53e8cebd33d4eb9d3bb Mon Sep 17 00:00:00 2001
From: Bogdan Abaev <bogdan@zotero.org>
Date: Fri, 28 Jul 2023 23:12:31 +0000
Subject: [PATCH 10/13] itemTags primary key including itemID

---
 misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects b/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects
index 95be90cb..6deece96 100755
--- a/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects
+++ b/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects
@@ -48,7 +48,7 @@ foreach ($shardIDs as $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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;", false, $shardID);
+	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`) ) 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);

From 327f68e0f2d93f429937e727821c6e8904f3953a Mon Sep 17 00:00:00 2001
From: Bogdan Abaev <bogdan@zotero.org>
Date: Mon, 31 Jul 2023 19:19:03 +0000
Subject: [PATCH 11/13] final cleanup, tests passing

---
 controllers/TagsController.php |   8 +
 model/Creator.inc.php          | 132 +-----------
 model/Creators.inc.php         |  16 +-
 model/Item.inc.php             |  43 +---
 model/Items.inc.php            |   2 +-
 model/Tag.inc.php              | 368 ++-------------------------------
 model/Tags.inc.php             |  22 +-
 7 files changed, 60 insertions(+), 531 deletions(-)

diff --git a/controllers/TagsController.php b/controllers/TagsController.php
index b273ec8b..47f55136 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 (in_array($GLOBALS['shardID'], $GLOBALS['updatedShards']) ) {
+								$tags = $item->getTags(true);
+								$tagIDs = array_map(function($tag) {
+									return $tag->id;
+								}, $tags);
+							} else {
+								$tagIDs = $item->getTags(true);
+							}
 							break;
 						
 						default:
diff --git a/model/Creator.inc.php b/model/Creator.inc.php
index a7a58e53..8e7bb8fc 100644
--- a/model/Creator.inc.php
+++ b/model/Creator.inc.php
@@ -27,7 +27,6 @@
 class Zotero_Creator {
 	private $id;
 	private $libraryID;
-	private $itemID;
 	private $firstName = '';
 	private $lastName = '';
 	private $shortName = '';
@@ -38,10 +37,9 @@ class Zotero_Creator {
 
 	
 	
-	public function __construct($id, $libraryID, $itemID, $firstName, $lastName, $fieldMode, $creatorTypeID, $orderIndex) {
+	public function __construct($id, $libraryID, $firstName, $lastName, $fieldMode, $creatorTypeID, $orderIndex) {
 		$this->id = $id;
 		$this->libraryID = $libraryID;
-		$this->itemID = $itemID;
 		$this->firstName = $firstName;
 		$this->lastName = $lastName;
 		$this->fieldMode = $fieldMode;
@@ -50,7 +48,6 @@ public function __construct($id, $libraryID, $itemID, $firstName, $lastName, $fi
 		$this->changed = array();
 		$props = array(
 			'libraryID',
-			'itemID',
 			'firstName',
 			'lastName',
 			'shortName',
@@ -78,7 +75,6 @@ public function __set($field, $value) {
 		switch ($field) {
 			case 'id':
 			case 'libraryID':
-			case 'itemID':
 				$this->checkValue($field, $value);
 				$this->$field = $value;
 				return;
@@ -104,132 +100,6 @@ public function hasChanged() {
 	}
 	
 	
-	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() && isset($this->id)) {
-			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");
-			
-			$timestamp = Zotero_DB::getTransactionTimestamp();
-			
-			$fields = "itemID=?, firstName=?, lastName=?, fieldMode=?, creatorTypeID=?, orderIndex=?";
-			$params = array(
-				$this->itemID,
-				$this->firstName,
-				$this->lastName,
-				$this->fieldMode,
-				$this->creatorTypeID,
-				$this->orderIndex
-			);
-			$shardID = Zotero_Shards::getByLibraryID($this->libraryID);
-			
-			try {
-				if ($isNew) {
-					$sql = "INSERT INTO itemCreators SET creatorID=?, $fields";
-					$stmt = Zotero_DB::getStatement($sql, true, $shardID);
-					Zotero_DB::queryFromStatement($stmt, array_merge(array($creatorID), $params));
-					
-				}
-				else {
-					$sql = "UPDATE itemCreators 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();
-			
-		}
-		catch (Exception $e) {
-			Zotero_DB::rollback();
-			throw ($e);
-		}
-		
-		// If successful, set values in object
-		if (!$this->id) {
-			$this->id = $creatorID;
-		}
-
-		
-		if ($isNew) {
-			Zotero_Creators::cache($this);
-		}
-		
-		// TODO: invalidate memcache?
-	}
-	
-	
-	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) {		
diff --git a/model/Creators.inc.php b/model/Creators.inc.php
index db4c5aa9..d28f737d 100644
--- a/model/Creators.inc.php
+++ b/model/Creators.inc.php
@@ -52,19 +52,26 @@ public static function idsDoNotExist($libraryID, $creators) {
 		}, $result);
 		return array_diff($creatorIDs, $existingIDs);
 	}
+	
+	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 bulkInsert($libraryID, $orderedCreators) {
+	public static function bulkInsert($libraryID, $itemID, $creators) {
 		$placeholdersArray = array();
 		$paramList = array();
-		foreach ($orderedCreators as $order => $creator) {
-			if (isset($creator->id)) {
+		foreach ($creators as $creator) {
+			$creatorID = $creator->id;
+			if (isset($creatorID)) {
 				throw new Exception("Insert not possible for creator with a set creatorID");
 			}
 			$creator->id = Zotero_ID::get('creators');
 			$placeholdersArray[] = "(?, ?, ?, ?, ?, ?, ?)";
 			$paramList = array_merge($paramList, [
 				$creator->id,
-				$creator->itemID,
+				$itemID,
 				$creator->firstName,
 				$creator->lastName,
 				$creator->fieldMode,
@@ -77,7 +84,6 @@ public static function bulkInsert($libraryID, $orderedCreators) {
 
 		$stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($libraryID));
 		Zotero_DB::queryFromStatement($stmt, $paramList);
-		return $orderedCreators;
 	}
 	
 	
diff --git a/model/Item.inc.php b/model/Item.inc.php
index 233790f5..6535eb78 100644
--- a/model/Item.inc.php
+++ b/model/Item.inc.php
@@ -1223,16 +1223,13 @@ public function save($userID=false) {
 				if (!empty($this->changed['creators'])) {
 					$indexes = array_keys($this->changed['creators']);
 					
-					// TODO: group queries
-					
 					$creatorsArray = [];
 					foreach ($indexes as $orderIndex) {
 						$creator = $this->getCreator($orderIndex);
-						$creator->itemID = $itemID;
 						$creatorsArray[] = $creator;
 					}
 					
-					Zotero_Creators::bulkInsert($this->libraryID, $creatorsArray);
+					Zotero_Creators::bulkInsert($this->libraryID, $itemID, $creatorsArray);
 
 				}
 				
@@ -1527,10 +1524,7 @@ public function save($userID=false) {
 					if ($this->isEmbeddedImageAttachment()) {
 						throw new Exception("Embedded image attachments cannot have tags");
 					}
-					foreach ($this->tags as $tag) {
-						$tag->itemID = $itemID;
-					}
-					Zotero_Tags::bulkInsert($this->libraryID, $this->tags);
+					Zotero_Tags::bulkInsert($this->libraryID, $itemID, $this->tags);
 				}
  				
 				// Related items
@@ -1708,26 +1702,14 @@ public function save($userID=false) {
 				//
 				if (!empty($this->changed['creators'])) {
 					$indexes = array_keys($this->changed['creators']);
-					
+
+					$toAdd = [];
 					foreach ($indexes as $orderIndex) {
-						Z_Core::debug('Creator in position ' . $orderIndex . ' has changed', 4);
 						$creator = $this->getCreator($orderIndex);
-						
-						// TODO: can do one update instead of delete and save()
-						$sql2 = 'DELETE FROM itemCreators WHERE itemID=? AND orderIndex=?';
-						Zotero_DB::query($sql2, array($this->_id, $orderIndex), $shardID);
-						
-						if (!$creator) {
-							continue;
-						}
-						
-						if ($creator->hasChanged() || !isset($creator->id)) {
-							$creator->itemID = $this->_id;
-							Z_Core::debug("Auto-saving changed creator {$creator->id}");
-							$creator->save();
-						}
-						
+						$toAdd[] = $creator;
 					}
+					Zotero_Creators::bulkDelete($this->libraryID, $this->_id, $indexes);
+					Zotero_Creators::bulkInsert($this->libraryID, $this->_id, $toAdd);
 					
 				}
 				
@@ -2162,10 +2144,7 @@ public function save($userID=false) {
 					$toAdd = array_udiff($newTags, $oldTags, $cmp);
 					$toRemove = array_udiff($oldTags, $newTags, $cmp);
 					
-					foreach ($toAdd as $tag) {
-						$tag->itemID = $this->_id;
-					}
-					Zotero_Tags::bulkInsert($this->_libraryID, $toAdd);
+					Zotero_Tags::bulkInsert($this->_libraryID, $this->_id,  $toAdd);
 					Zotero_Tags::bulkDelete($this->_libraryID, $this->_id, $toRemove);
 				}
 				
@@ -3676,7 +3655,7 @@ public function setTags($newTags) {
 				continue;
 			}
 			$version = Zotero_Libraries::getUpdatedVersion($this->libraryID);
-			$this->tags[] = new Zotero_Tag(null, $this->libraryID, null, $name, $type, $version);
+			$this->tags[] = new Zotero_Tag(null, $this->libraryID, $name, $type, $version);
 			$this->changed['tags'] = true;
 		}
 		$toRemove = array_diff($existingTagNames, $foundNames);
@@ -4685,7 +4664,7 @@ protected function loadCreators($reload = false) {
 		}
 
 		foreach ($creators as $creator) {
-			$creatorObj = new Zotero_Creator($creator['creatorID'], $this->_libraryID, null, $creator['firstName'], $creator['lastName'], $creator['fieldMode'], $creator['creatorTypeID'], $creator['orderIndex']);
+			$creatorObj = new Zotero_Creator($creator['creatorID'], $this->_libraryID, $creator['firstName'], $creator['lastName'], $creator['fieldMode'], $creator['creatorTypeID'], $creator['orderIndex']);
 
 			$this->creators[$creator['orderIndex']] = $creatorObj;
 		}
@@ -4731,7 +4710,7 @@ protected function loadTags($reload = false) {
 		$this->tags = [];
 		if ($tags) {
 			foreach ($tags as $tag) {
-				$this->tags[] = new Zotero_Tag($tag['tagID'], $this->libraryID, $tag['itemID'], $tag['name'], $tag['type'], $tag['version']);
+				$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 148b5943..47467e7d 100644
--- a/model/Items.inc.php
+++ b/model/Items.inc.php
@@ -1719,7 +1719,7 @@ public static function updateFromJSON(Zotero_Item $item,
 						$newCreatorTypeID = Zotero_CreatorTypes::getID($newCreatorData->creatorType);
 
 						// Make creator object
-						$newCreator = new Zotero_Creator(null, $item->libraryID, null, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode, $newCreatorTypeID, $orderIndex);
+						$newCreator = new Zotero_Creator(null, $item->libraryID, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode, $newCreatorTypeID, $orderIndex);
 						$item->setCreator($orderIndex, $newCreator);
 					}
 
diff --git a/model/Tag.inc.php b/model/Tag.inc.php
index db7fc6da..c2f79175 100644
--- a/model/Tag.inc.php
+++ b/model/Tag.inc.php
@@ -27,27 +27,21 @@
 class Zotero_Tag {
 	private $id;
 	private $libraryID;
-	private $itemID;
 	private $name;
 	private $type;
 	private $version;
 	
 	private $changed;
-	private $previousData;
 	
-	private $linkedItemsLoaded = false;
-	private $linkedItems = array();
+	private $linkedItemsCount;
+
 	
-	public function __construct($id, $libraryID, $itemID, $name, $type, $version) {
+	public function __construct($id, $libraryID, $name, $type, $version) {
 		$this->__set("id", $id);
 		$this->__set("libraryID", $libraryID);
-		$this->__set("itemID", $itemID);
 		$this->__set("name", $name);
 		$this->__set("type", $type);
 		$this->__set("version", $version);		
-
-		$this->previousData = array();
-		$this->linkedItemsLoaded = false;
 		
 		$this->changed = array();
 		$props = array(
@@ -72,332 +66,22 @@ public function __get($field) {
 	
 	
 	public function __set($field, $value) {
-		switch ($field) {
-			case 'id':
-			case 'libraryID':
-			case 'itemID':
-				$this->checkValue($field, $value);
-				$this->$field = $value;
-				return;
-		}
-		
 		
 		$this->checkValue($field, $value);
 		
 		if ($this->$field !== $value) {
-			$this->prepFieldChange($field);
 			$this->$field = $value;
 		}
 	}
 	
-	
-	
-	
-	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;
+	public function getLinkedItemsCount() { 
+		if (!$this->linkedItemsCount) {
+			$this->loadLinkedItemsCount();
 		}
-		
-		$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 'dateg' from test
-		$changed = $this->changed;
-		// if (!empty($changed['dateModified'])) {
-		// 	unset($changed['dateModified']);
-		// }
-		return in_array(true, array_values($changed));
+		return $this->linkedItemsCount;
 	}
 	
 	
-	// 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;
-	// 		$version = ($this->changed['name'] || $this->changed['type'])
-	// 			? Zotero_Libraries::getUpdatedVersion($this->libraryID)
-	// 			: $this->version;
-			
-	// 		$fields = "name=?, itemID=?, `type`=?, version=?";
-	// 		$params = array(
-	// 			$this->name,
-	// 			$this->itemID,
-	// 			$this->type ? $this->type : 0,
-	// 			$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();
-			
-	// 	}
-	// 	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(
-			'tagID' => $this->id,
-			'name' => $this->name,
-			'type' => $this->type,
-			'linkedItems' => $this->getLinkedItems(true),
-		);
-		
-		return $obj;
-	}
-	
 	
 	public function toResponseJSON() {
 		
@@ -426,7 +110,7 @@ public function toResponseJSON() {
 			'type' => $this->type,
 			'numItems' => isset($fixedValues['numItems'])
 				? $fixedValues['numItems']
-				: sizeOf($this->getLinkedItems(true))
+				: $this->getLinkedItemsCount()
 		];
 		
 		return $json;
@@ -487,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',
@@ -517,25 +201,16 @@ public function toAtom($queryParams, $fixedValues=null) {
 	
 	
 	
-	private function loadLinkedItems() {
-		Z_Core::debug("Loading linked items for tag $this->id");
-		
-		// if (!$this->id) {
-		// 	$this->linkedItemsLoaded = true;
-		// 	return;
-		// }
+	private function loadLinkedItemsCount() {
+		Z_Core::debug("Loading linked items count for tag $this->id");
 		
-		// if (!$this->id) {
-		// 	$this->linkedItemsLoaded = true;
-		// 	return;
-		// }
+		if (!$this->id) {
+			throw new Exception("id is required to fetch linked items count");
+		}
 		
-		$sql = "SELECT itemID FROM itemTags JOIN items USING (itemID) WHERE name=? AND libraryID=?";
+		$sql = "SELECT COUNT(*) FROM itemTags JOIN items USING (itemID) WHERE name=? AND libraryID=?";
 		$stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID));
-		$itemIds = Zotero_DB::columnQueryFromStatement($stmt, [$this->name, $this->libraryID]);
-		
-		$this->linkedItems = $itemIds ? $itemIds : array();
-		$this->linkedItemsLoaded = true;
+		$this->linkedItemsCount = Zotero_DB::columnQueryFromStatement($stmt, [$this->name, $this->libraryID]);
 	}
 	
 	
@@ -550,7 +225,6 @@ private function checkValue($field, $value) {
 		switch ($field) {
 			case 'id':
 			case 'libraryID':
-			case 'itemID':
 				if (!Zotero_Utilities::isPosInt($value)) {
 					$this->invalidValueError($field, $value);
 				}
@@ -565,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->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 8b6906a1..41300722 100644
--- a/model/Tags.inc.php
+++ b/model/Tags.inc.php
@@ -78,13 +78,12 @@ public static function bulkDelete($libraryID, $itemID, $tags) {
 	}
 
 
-	public static function bulkInsert($libraryID, $tags) {
+	public static function bulkInsert($libraryID, $itemID, $tags) {
 		if (sizeof($tags) == 0){
 			return;
 		}
 		$placeholdersArray = array();
 		$paramList = array();
-		$itemIDs = [];
 		foreach ($tags as $tag) {
 			if (isset($tag->id)) {
 				throw new Exception("Insert not possible for tag with a set tagID");
@@ -93,12 +92,11 @@ public static function bulkInsert($libraryID, $tags) {
 	
 			$existinTagData = Zotero_DB::query($existingTagsSql, [$tag->name, $libraryID], Zotero_Shards::getByLibraryID($libraryID));
 	
-			$itemIDs[] = $tag->itemID;
 			$tag->id = sizeof($existinTagData) > 0 ? $existinTagData[0]['tagID'] : Zotero_ID::get('tags');
 			$placeholdersArray[] = "(?, ?, ?, ?, ?)";
 			$paramList = array_merge($paramList, [
 				$tag->id,
-				$tag->itemID,
+				$itemID,
 				$tag->name,
 				$tag->type,
 				sizeof($existinTagData) > 0 ? $existinTagData[0]['version'] : $tag->version,
@@ -125,20 +123,24 @@ public static function bulkGet($libraryID, $tagIDs) {
 		$tags = Zotero_DB::queryFromStatement($stmt, $tagIDs);
 		$tagObjects = [];
 		foreach($tags as $tag) {
-			$tagObjects[] = new Zotero_Tag($tag['tagID'], $libraryID, null, $tag['name'], $tag['type'], null);
+			$tagObjects[] = new Zotero_Tag($tag['tagID'], $libraryID, $tag['name'], $tag['type'], null);
 		}
 		
 		return $tagObjects;
 	}
 
-	public static function loadLinkedItemsKeys($libraryID, $tagID) {
-		$sql = "SELECT `key` FROM itemTags JOIN items USING (itemID) WHERE tagID=? AND libraryID=?";
+	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, [$tagID, $libraryID]);
-		return $itemKeys;
+		$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)
 	 */

From 29568302c94ec2f775551cde75db772f10cf805b Mon Sep 17 00:00:00 2001
From: Bogdan Abaev <bogdan@zotero.org>
Date: Mon, 31 Jul 2023 22:16:23 +0000
Subject: [PATCH 12/13] moved checking of previously existing items outside of
 the loop

---
 model/Tags.inc.php | 24 ++++++++++++++++++------
 1 file changed, 18 insertions(+), 6 deletions(-)

diff --git a/model/Tags.inc.php b/model/Tags.inc.php
index 41300722..85d5a5b6 100644
--- a/model/Tags.inc.php
+++ b/model/Tags.inc.php
@@ -84,22 +84,34 @@ public static function bulkInsert($libraryID, $itemID, $tags) {
 		}
 		$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;
+		}
 		foreach ($tags as $tag) {
 			if (isset($tag->id)) {
 				throw new Exception("Insert not possible for tag with a set tagID");
 			}
-			$existingTagsSql = "SELECT t.tagID, t.version from itemTags t JOIN items i USING (itemID) WHERE name = ? AND libraryID = ? ORDER BY version LIMIT 1;"; 
-	
-			$existinTagData = Zotero_DB::query($existingTagsSql, [$tag->name, $libraryID], Zotero_Shards::getByLibraryID($libraryID));
-	
-			$tag->id = sizeof($existinTagData) > 0 ? $existinTagData[0]['tagID'] : Zotero_ID::get('tags');
+			$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,
-				sizeof($existinTagData) > 0 ? $existinTagData[0]['version'] : $tag->version,
+				$existingTag['version'] ? $existingTag['version'] : $tag->version,
 			 ]);
 		}
 

From 938f1fe4c987849d89231d77420fb5ab0e363b71 Mon Sep 17 00:00:00 2001
From: Bogdan Abaev <bogdan@zotero.org>
Date: Thu, 3 Aug 2023 04:04:27 +0000
Subject: [PATCH 13/13] no creatorID, added key to itemTags, tag insert
 performace improvement, formating

---
 controllers/ApiController.php                 |  3 +-
 controllers/ItemsController.php               |  2 +-
 controllers/TagsController.php                |  4 +-
 include/header.inc.php                        |  2 +-
 .../creatorsAsNonClassicDataObjects           | 34 ++++++-------
 model/Creator.inc.php                         |  6 +--
 model/Creators.inc.php                        | 49 ++-----------------
 model/Item.inc.php                            | 36 +++++++-------
 model/Items.inc.php                           |  2 +-
 9 files changed, 45 insertions(+), 93 deletions(-)

diff --git a/controllers/ApiController.php b/controllers/ApiController.php
index 932603ca..2d46a9ea 100644
--- a/controllers/ApiController.php
+++ b/controllers/ApiController.php
@@ -368,7 +368,7 @@ public function init($extra) {
 		}
 		// Temporarily record shardID in GLOBALS so we can access it in header.inc.php
 		if (isset($this->objectLibraryID)) {
-			$GLOBALS['shardID'] =  Zotero_Shards::getByLibraryID($this->objectLibraryID);
+			define('Z_TEMP_SHARD_MIGRATED', in_array(Zotero_Shards::getByLibraryID($this->objectLibraryID), $GLOBALS['updatedShards']));
 		}
 		
 		$apiVersion = !empty($_SERVER['HTTP_ZOTERO_API_VERSION'])
@@ -560,7 +560,6 @@ public function testSetup() {
 		}
 		
 		function getUserKey($userID) {
-			$GLOBALS['shardID'] = Zotero_Shards::getByUserID($userID);
 			$keys = Zotero_Keys::getUserKeys($userID);
 			foreach ($keys as $keyObj) {
 				$keyObj->erase();
diff --git a/controllers/ItemsController.php b/controllers/ItemsController.php
index df3fc1da..c0c96745 100644
--- a/controllers/ItemsController.php
+++ b/controllers/ItemsController.php
@@ -378,7 +378,7 @@ public function items() {
 						if (!$tagIDs) {
 							$this->e404("Tag not found");
 						}
-						if (in_array($GLOBALS['shardID'], $GLOBALS['updatedShards']) ) {
+						if (Z_TEMP_SHARD_MIGRATED) {
 							$linkedItemKeys = Zotero_Tags::loadLinkedItemsKeys($this->objectLibraryID,  $this->scopeObjectName);
 							$itemKeys = array_merge($itemKeys, $linkedItemKeys);
 						}
diff --git a/controllers/TagsController.php b/controllers/TagsController.php
index 47f55136..428a5f49 100644
--- a/controllers/TagsController.php
+++ b/controllers/TagsController.php
@@ -172,7 +172,7 @@ public function tags() {
 							}
 							$title = "Tags of '" . $item->getDisplayTitle() . "'";
 							$tagIDs = $item->getTags(true);
-							if (in_array($GLOBALS['shardID'], $GLOBALS['updatedShards']) ) {
+							if (Z_TEMP_SHARD_MIGRATED) {
 								$tags = $item->getTags(true);
 								$tagIDs = array_map(function($tag) {
 									return $tag->id;
@@ -197,7 +197,7 @@ public function tags() {
 				Zotero_DB::beginTransaction();
 				// Different delete behavior depending on if we are on migrated shard or not
 				// because after migration $tag->key does not exist
-				if (in_array($GLOBALS['shardID'], $GLOBALS['updatedShards']) ) {
+				if (Z_TEMP_SHARD_MIGRATED) {
 					$tagIDs = [];
 					foreach ($tagNames as $tagName) {
 						$tagIDs = array_merge($tagIDs, Zotero_Tags::getIDs($this->objectLibraryID, $tagName));
diff --git a/include/header.inc.php b/include/header.inc.php
index 206f524f..ec06dc1c 100644
--- a/include/header.inc.php
+++ b/include/header.inc.php
@@ -53,7 +53,7 @@ function zotero_autoload($className) {
 			"Tags.inc.php",
 			"TagsController.php"
 		];
-		if (isset($GLOBALS['shardID']) && !in_array($GLOBALS['shardID'], $updatedShards) && in_array($fileName, $newFiles)) {
+		if (defined('Z_TEMP_SHARD_MIGRATED') && !Z_TEMP_SHARD_MIGRATED && in_array($fileName, $newFiles)) {
 			$path = Z_ENV_BASE_PATH . 'model/old_';
 		}
 		else {
diff --git a/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects b/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects
index 6deece96..2e5f4cc9 100755
--- a/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects
+++ b/misc/db-updates/2023-07-17/creatorsAsNonClassicDataObjects
@@ -22,50 +22,50 @@ foreach ($shardIDs as $shardID) {
 	// 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);
+	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` ( `creatorID` BIGINT UNSIGNED NOT NULL, `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 (`creatorID`, `itemID`), KEY `creatorTypeID` (`creatorTypeID`), KEY `name` (`lastName`(7),`firstName`(6)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;", false, $shardID);
+	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);
+	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 (creatorID, firstName, lastName, fieldMode, itemID, creatorTypeID, orderIndex ) SELECT creatorID, firstName, lastName, fieldMode, itemID, creatorTypeID, orderIndex from creators INNER JOIN itemCreators USING (creatorID);", false, $shardID);
+	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);
+	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);
+	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);
+	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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;", false, $shardID);
+	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);
+	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);
+	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);
+	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);
+	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);
+	Zotero_DB::query("UPDATE shards SET state='up' WHERE shardID=?", $shardID);
 	echo "Done with shard $shardID\n\n";
 	sleep(1);
 }
diff --git a/model/Creator.inc.php b/model/Creator.inc.php
index 8e7bb8fc..f9fc1b3d 100644
--- a/model/Creator.inc.php
+++ b/model/Creator.inc.php
@@ -25,7 +25,6 @@
 */
 
 class Zotero_Creator {
-	private $id;
 	private $libraryID;
 	private $firstName = '';
 	private $lastName = '';
@@ -37,8 +36,7 @@ class Zotero_Creator {
 
 	
 	
-	public function __construct($id, $libraryID, $firstName, $lastName, $fieldMode, $creatorTypeID, $orderIndex) {
-		$this->id = $id;
+	public function __construct($libraryID, $firstName, $lastName, $fieldMode, $creatorTypeID, $orderIndex) {
 		$this->libraryID = $libraryID;
 		$this->firstName = $firstName;
 		$this->lastName = $lastName;
@@ -73,7 +71,6 @@ public function __get($field) {
 	
 	public function __set($field, $value) {
 		switch ($field) {
-			case 'id':
 			case 'libraryID':
 				$this->checkValue($field, $value);
 				$this->$field = $value;
@@ -117,7 +114,6 @@ private function checkValue($field, $value) {
 		
 		// Data validation
 		switch ($field) {
-			case 'id':
 			case 'libraryID':
 			case 'creatorTypeID':
 				if (!Zotero_Utilities::isPosInt($value)) {
diff --git a/model/Creators.inc.php b/model/Creators.inc.php
index d28f737d..2fb8c3fe 100644
--- a/model/Creators.inc.php
+++ b/model/Creators.inc.php
@@ -37,40 +37,20 @@ class Zotero_Creators {
 	private static $maxLastNameLength = 255;
 	
 	private static $creatorsByID = array();
-	private static $primaryDataByCreatorID = array();
 	private static $primaryDataByLibraryAndKey = array();
 	
-	public static function idsDoNotExist($libraryID, $creators) {
-		$creatorIDs = array_map(function ($object) {
-			return $object['creatorID'];
-		}, $creators);
-		$placeholders = implode(',', array_fill(0, count($creatorIDs), '?'));
-		$sql = "SELECT creatorID FROM itemCreators WHERE creatorID IN ($placeholders)";
-		$result = Zotero_DB::query($sql, $creatorIDs, Zotero_Shards::getByLibraryID($libraryID));
-		$existingIDs = array_map(function ($object) {
-			return $object['creatorID'];
-		}, $result);
-		return array_diff($creatorIDs, $existingIDs);
-	}
-	
 	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));
+		Zotero_DB::query($sql, array_merge([$itemID], $creatorOrdersArray), Zotero_Shards::getByLibraryID($libraryID));
 	}
 
 	public static function bulkInsert($libraryID, $itemID, $creators) {
 		$placeholdersArray = array();
 		$paramList = array();
 		foreach ($creators as $creator) {
-			$creatorID = $creator->id;
-			if (isset($creatorID)) {
-				throw new Exception("Insert not possible for creator with a set creatorID");
-			}
-			$creator->id = Zotero_ID::get('creators');
-			$placeholdersArray[] = "(?, ?, ?, ?, ?, ?, ?)";
+			$placeholdersArray[] = "(?, ?, ?, ?, ?, ?)";
 			$paramList = array_merge($paramList, [
-				$creator->id,
 				$itemID,
 				$creator->firstName,
 				$creator->lastName,
@@ -80,36 +60,13 @@ public static function bulkInsert($libraryID, $itemID, $creators) {
 			 ]);
 		}
 		$placeholdersStr = implode(", ", $placeholdersArray);
-		$sql = "INSERT INTO itemCreators (creatorID, itemID, firstName, lastName, fieldMode, creatorTypeID, orderIndex) VALUES $placeholdersStr";
+		$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])) {
diff --git a/model/Item.inc.php b/model/Item.inc.php
index 6535eb78..27a8d104 100644
--- a/model/Item.inc.php
+++ b/model/Item.inc.php
@@ -2353,20 +2353,13 @@ public function setCreator($orderIndex, Zotero_Creator $creator) {
 			$creatorTypeID = Zotero_CreatorTypes::getPrimaryIDForType($this->itemTypeID);
 		}
 		
-		// If creator already exists at this position, cancel
-		if (isset($this->creators[$orderIndex])
-				&& $this->creators[$orderIndex]->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;
 		}
-		return true;
+		return false;
 	}
 	
 	
@@ -3614,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)) {
@@ -3633,6 +3626,8 @@ public function setTags($newTags) {
 			return $tag->name;
 		}, $this->tags);
 		$foundNames = [];
+		$tagArray = [];
+		$changed = false;
 		foreach ($newTags as $newTag) {
 			// Allow the passed array to contain either strings or objects
 			if (is_string($newTag)) {
@@ -3655,8 +3650,12 @@ public function setTags($newTags) {
 				continue;
 			}
 			$version = Zotero_Libraries::getUpdatedVersion($this->libraryID);
-			$this->tags[] = new Zotero_Tag(null, $this->libraryID, $name, $type, $version);
-			$this->changed['tags'] = true;
+			$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)) {
@@ -4655,16 +4654,17 @@ protected function loadCreators($reload = false) {
 		}
 
 		if ($cache_used) {
-			$creatorsNotFound = Zotero_Creators::idsDoNotExist($this->libraryID, $creators);
+			// 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($creatorsNotFound as $missingCreator) {
+			// 	Z_Core::$MC->delete($cacheKey);
+			// 	throw new Exception("Creator {$creator['creatorID']} not found");
+			// }
 		}
 
 		foreach ($creators as $creator) {
-			$creatorObj = new Zotero_Creator($creator['creatorID'], $this->_libraryID, $creator['firstName'], $creator['lastName'], $creator['fieldMode'], $creator['creatorTypeID'], $creator['orderIndex']);
+			$creatorObj = new Zotero_Creator($this->_libraryID, $creator['firstName'], $creator['lastName'], $creator['fieldMode'], $creator['creatorTypeID'], $creator['orderIndex']);
 
 			$this->creators[$creator['orderIndex']] = $creatorObj;
 		}
diff --git a/model/Items.inc.php b/model/Items.inc.php
index 47467e7d..95a75247 100644
--- a/model/Items.inc.php
+++ b/model/Items.inc.php
@@ -1719,7 +1719,7 @@ public static function updateFromJSON(Zotero_Item $item,
 						$newCreatorTypeID = Zotero_CreatorTypes::getID($newCreatorData->creatorType);
 
 						// Make creator object
-						$newCreator = new Zotero_Creator(null, $item->libraryID, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode, $newCreatorTypeID, $orderIndex);
+						$newCreator = new Zotero_Creator($item->libraryID, $newCreatorData->firstName, $newCreatorData->lastName, $newCreatorData->fieldMode, $newCreatorTypeID, $orderIndex);
 						$item->setCreator($orderIndex, $newCreator);
 					}
 
" . $note . "