public function save($userID = false) { if (!$this->libraryID) { trigger_error("Library ID must be set before saving", E_USER_ERROR); } Zotero_Items::editCheck($this); if (!$this->hasChanged()) { Z_Core::debug("Item {$this->id} has not changed"); return 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++; } $shardID = Zotero_Shards::getByLibraryID($this->libraryID); Zotero_DB::beginTransaction(); try { // // New item, insert and return id // if (!$this->id || !$this->exists()) { Z_Core::debug('Saving data for new item to database'); $isNew = true; $sqlColumns = array(); $sqlValues = array(); // // Primary fields // $itemID = $this->id ? $this->id : Zotero_ID::get('items'); $key = $this->key ? $this->key : $this->generateKey(); $sqlColumns = array('itemID', 'itemTypeID', 'libraryID', 'key', 'dateAdded', 'dateModified', 'serverDateModified'); $timestamp = Zotero_DB::getTransactionTimestamp(); $sqlValues = array($itemID, $this->itemTypeID, $this->libraryID, $key, $this->dateAdded ? $this->dateAdded : $timestamp, $this->dateModified ? $this->dateModified : $timestamp, $timestamp); // // Primary fields // $sql = 'INSERT INTO items (`' . implode('`, `', $sqlColumns) . '`) VALUES ('; // Insert placeholders for bind parameters for ($i = 0; $i < sizeOf($sqlValues); $i++) { $sql .= '?, '; } $sql = substr($sql, 0, -2) . ')'; // Save basic data to items table $insertID = Zotero_DB::query($sql, $sqlValues, $shardID); if (!$this->id) { if (!$insertID) { throw new Exception("Item id not available after INSERT"); } $itemID = $insertID; $this->serverDateModified = $timestamp; } // Group item data if (Zotero_Libraries::getType($this->libraryID) == 'group' && $userID) { $sql = "INSERT INTO groupItems VALUES (?, ?, ?)"; Zotero_DB::query($sql, array($itemID, $userID, null), $shardID); } // // ItemData // if ($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($this->itemTypeID, $fieldID); throw new Exception("={$fieldName} field " . "'" . substr($value, 0, 50) . "...' 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 ($this->changed['creators']) { $indexes = array_keys($this->changed['creators']); // TODO: group queries $sql = "INSERT INTO itemCreators\n\t\t\t\t\t\t\t\t(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}"); $creator['ref']->save(); } $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 ($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, $itemID, $shardID); } // Note if ($this->isNote() || $this->changed['note']) { $noteIsSanitized = false; // If we don't have a sanitized note, generate one if (is_null($this->noteTextSanitized)) { $noteTextSanitized = Zotero_Notes::sanitize($this->noteText); // But if the same as original, just use reference if ($this->noteText == $noteTextSanitized) { $this->noteTextSanitized =& $this->noteText; $noteIsSanitized = true; } else { $this->noteTextSanitized = $noteTextSanitized; } } // If note is sanitized already, store empty string // If not, store sanitized version $noteTextSanitized = $noteIsSanitized ? '' : $this->noteTextSanitized; $title = Zotero_Notes::noteToTitle($this->noteTextSanitized); $sql = "INSERT INTO itemNotes\n\t\t\t\t\t\t\t(itemID, sourceItemID, note, noteSanitized, title, hash)\n\t\t\t\t\t\t\tVALUES (?,?,?,?,?,?)"; $parent = $this->isNote() ? $this->getSource() : null; $hash = $this->noteText ? md5($this->noteText) : ''; $bindParams = array($itemID, $parent ? $parent : null, $this->noteText ? $this->noteText : '', $noteTextSanitized, $title, $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($title, 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\n\t\t\t\t\t\t\t(itemID, sourceItemID, linkMode, mimeType, charsetID, path, storageModTime, storageHash)\n\t\t\t\t\t\t\tVALUES (?,?,?,?,?,?,?,?)"; $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()) { trigger_error("Parent item cannot be a child attachment", E_USER_ERROR); } } $linkMode = $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); } // Sort fields $sortTitle = Zotero_Items::getSortTitle($this->getDisplayTitle(true)); if (mb_substr($sortTitle, 0, 5) == mb_substr($this->getField('title', false, true), 0, 5)) { $sortTitle = null; } $creatorSummary = $this->isRegularItem() ? mb_strcut($this->getCreatorSummary(), 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; } } // Related items if (!empty($this->changed['relatedItems'])) { $removed = array(); $newids = array(); $currentIDs = $this->relatedItems; if (!$currentIDs) { $currentIDs = array(); } foreach ($this->previousData['relatedItems'] as $id) { if (!in_array($id, $currentIDs)) { $removed[] = $id; } } foreach ($currentIDs as $id) { if (in_array($id, $this->previousData['relatedItems'])) { continue; } $newids[] = $id; } if ($removed) { $sql = "DELETE FROM itemRelated WHERE itemID=?\n\t\t\t\t\t\t\t\tAND linkedItemID IN ("; $sql .= implode(', ', array_fill(0, sizeOf($removed), '?')) . ")"; Zotero_DB::query($sql, array_merge(array($this->id), $removed), $shardID); } if ($newids) { $sql = "INSERT INTO itemRelated (itemID, linkedItemID)\n\t\t\t\t\t\t\t\tVALUES (?,?)"; $insertStatement = Zotero_DB::getStatement($sql, false, $shardID); foreach ($newids as $linkedItemID) { $insertStatement->execute(array($itemID, $linkedItemID)); } } } // Remove from delete log if it's there $sql = "DELETE FROM syncDeleteLogKeys WHERE libraryID=? AND objectType='item' AND `key`=?"; Zotero_DB::query($sql, array($this->libraryID, $key), $shardID); } else { Z_Core::debug('Updating database with new item data', 4); $isNew = false; // // Primary fields // $sql = "UPDATE items SET "; $sqlValues = array(); $timestamp = Zotero_DB::getTransactionTimestamp(); $updateFields = array('itemTypeID', 'libraryID', 'key', 'dateAdded', 'dateModified'); foreach ($updateFields as $updateField) { if (in_array($updateField, $this->changed['primaryData'])) { $sql .= "`{$updateField}`=?, "; $sqlValues[] = $this->{$updateField}; } else { if ($updateField == 'dateModified') { $sql .= "`{$updateField}`=?, "; $sqlValues[] = $timestamp; } } } $sql .= "serverDateModified=?, version=IF(version = 65535, 0, version + 1) WHERE itemID=?"; array_push($sqlValues, $timestamp, $this->id); Zotero_DB::query($sql, $sqlValues, $shardID); $this->serverDateModified = $timestamp; // Group item data if (Zotero_Libraries::getType($this->libraryID) == 'group' && $userID) { $sql = "INSERT INTO groupItems VALUES (?, ?, ?)\n\t\t\t\t\t\t\t\tON DUPLICATE KEY UPDATE lastModifiedByUserID=?"; Zotero_DB::query($sql, array($this->id, null, $userID, $userID), $shardID); } // // ItemData // if ($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($this->itemTypeID, $fieldID); throw new Exception("={$fieldName} field " . "'" . substr($value, 0, 50) . "...' 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 ($this->changed['creators']) { $indexes = array_keys($this->changed['creators']); $sql = "INSERT INTO itemCreators\n\t\t\t\t\t\t\t\t(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 ($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); } // In case this was previously a standalone item, // delete from any collections it may have been in if ($this->changed['source'] && $this->getSource()) { $sql = "DELETE FROM collectionItems WHERE itemID=?"; Zotero_DB::query($sql, $this->id, $shardID); } // // Note or attachment note // if ($this->changed['note']) { $noteIsSanitized = false; // If we don't have a sanitized note, generate one if (is_null($this->noteTextSanitized)) { $noteTextSanitized = Zotero_Notes::sanitize($this->noteText); // But if the same as original, just use reference if ($this->noteText == $noteTextSanitized) { $this->noteTextSanitized =& $this->noteText; $noteIsSanitized = true; } else { $this->noteTextSanitized = $noteTextSanitized; } } // If note is sanitized already, store empty string // If not, store sanitized version $noteTextSanitized = $noteIsSanitized ? '' : $this->noteTextSanitized; $title = Zotero_Notes::noteToTitle($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\n\t\t\t\t\t\t\t(itemID, sourceItemID, note, noteSanitized, title, hash)\n\t\t\t\t\t\t\tVALUES (?,?,?,?,?,?)\n\t\t\t\t\t\t\tON DUPLICATE KEY UPDATE sourceItemID=?, note=?, noteSanitized=?, title=?, hash=?"; $bindParams = array($this->id, $sourceItemID, $this->noteText ? $this->noteText : '', $noteTextSanitized, $title, $hash, $sourceItemID, $this->noteText ? $this->noteText : '', $noteTextSanitized, $title, $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 ($this->changed['attachmentData']) { $sql = "REPLACE INTO itemAttachments\n\t\t\t\t\t\t(itemID, sourceItemID, linkMode, mimeType, charsetID, path, storageModTime, storageHash)\n\t\t\t\t\t\tVALUES (?,?,?,?,?,?,?,?)"; $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()) { trigger_error("Parent item cannot be a child attachment", E_USER_ERROR); } } $linkMode = $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); } // Sort fields if (!empty($this->changed['primaryData']['itemTypeID']) || $this->changed['itemData'] || $this->changed['creators']) { $sql = "UPDATE itemSortFields SET sortTitle=?"; $params = array(); $sortTitle = Zotero_Items::getSortTitle($this->getDisplayTitle(true)); if (mb_substr($sortTitle, 0, 5) == mb_substr($this->getField('title', false, true), 0, 5)) { $sortTitle = null; } $params[] = $sortTitle; if ($this->changed['creators']) { $creatorSummary = mb_strcut($this->getCreatorSummary(), 0, Zotero_Creators::$creatorSummarySortLength); $sql .= ", creatorSummary=?"; $params[] = $creatorSummary; } $sql .= " WHERE itemID=?"; $params[] = $this->id; Zotero_DB::query($sql, $params, $shardID); } // // Source item id // if ($this->changed['source']) { $type = Zotero_ItemTypes::getName($this->itemTypeID); $Type = ucwords($type); // Update DB, if not a note or attachment we already changed above if (!$this->changed['attachmentData'] && (!$this->changed['note'] || !$this->isNote())) { $sql = "UPDATE item" . $Type . "s SET sourceItemID=? WHERE itemID=?"; $parent = $this->getSource(); $bindParams = array($parent ? $parent : null, $this->id); Zotero_DB::query($sql, $bindParams, $shardID); } } if (false && $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=?\n\t\t\t\t\t\t\t\tWHERE 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); } } } // Related items if (!empty($this->changed['relatedItems'])) { $removed = array(); $newids = array(); $currentIDs = $this->relatedItems; if (!$currentIDs) { $currentIDs = array(); } foreach ($this->previousData['relatedItems'] as $id) { if (!in_array($id, $currentIDs)) { $removed[] = $id; } } foreach ($currentIDs as $id) { if (in_array($id, $this->previousData['relatedItems'])) { continue; } $newids[] = $id; } if ($removed) { $sql = "DELETE FROM itemRelated WHERE itemID=?\n\t\t\t\t\t\t\t\tAND linkedItemID IN ("; $q = array_fill(0, sizeOf($removed), '?'); $sql .= implode(', ', $q) . ")"; Zotero_DB::query($sql, array_merge(array($this->id), $removed), $shardID); } if ($newids) { $sql = "INSERT INTO itemRelated (itemID, linkedItemID)\n\t\t\t\t\t\t\t\tVALUES (?,?)"; $insertStatement = Zotero_DB::getStatement($sql, false, $shardID); foreach ($newids as $linkedItemID) { $insertStatement->execute(array($this->id, $linkedItemID)); } } } } Zotero_DB::commit(); } catch (Exception $e) { Zotero_DB::rollback(); throw $e; } if (!$this->id) { $this->id = $itemID; } if (!$this->key) { $this->key = $key; } if ($isNew) { Zotero_Items::cache($this); Zotero_Items::cacheLibraryKeyID($this->libraryID, $key, $itemID); } // TODO: invalidate memcache Zotero_Items::reload($this->libraryID, $this->id); if ($isNew) { //Zotero.Notifier.trigger('add', 'item', $this->getID()); return $this->id; } //Zotero.Notifier.trigger('modify', 'item', $this->getID(), { old: $this->_preChangeArray }); return true; }