diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a4d8c3ce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +**/secret.json +**/secret.txt +**/secret.yaml +.git +.github +.env +.vscode/ +bin +build +doc +docker-compose.yml +logs +#Zend +zotprime-k8s + diff --git a/.env_example b/.env_example new file mode 100644 index 00000000..32fb4085 --- /dev/null +++ b/.env_example @@ -0,0 +1,20 @@ +# Ip/host of the Dataserver. +DSHOST=http://localhost:8080/ + +#Version +VER=2.6.1-rc + +# Ip/host of the Minio. +S3HOST=10.5.5.1:9000 + +## SECRETS +#DB +MYSQLROOTPASSWORD=zotero +MYSQLDATABASE=zotprimeprod +MYSQLUSER=zotprimeprod +MYSQLPASSWORD=zotprimeprod + +#MINIO +MINIOROOTUSER=zotero +MINIOROOTPASSWORD=zoterodocker + diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..6e5fa57d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +build +logs +**/secret*.json +**/secret*.txt +**/secret*.yaml +**/zotprime*secret.yaml +docker-compose.yml +**/cred.json +.vscode/ diff --git a/.gitmodules b/.gitmodules index a6eebe35..fbdda8c1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,18 +1,22 @@ -[submodule "include/Elastica"] - path = include/Elastica - url = https://github.com/ruflin/Elastica.git [submodule "tinymce-clean-server"] path = tinymce-clean-server - url = https://github.com/zotero/tinymce-clean-server + url = https://github.com/uniuuu/tinymce-clean-server + branch = stage [submodule "stream-server"] path = stream-server - url = https://github.com/zotero/stream-server + url = https://github.com/uniuuu/stream-server + branch = stage [submodule "client/zotero-client"] path = client/zotero-client - url = https://github.com/zotero/zotero + url = https://github.com/uniuuu/zotero [submodule "client/zotero-standalone-build"] path = client/zotero-standalone-build - url = https://github.com/zotero/zotero-standalone-build + url = https://github.com/uniuuu/zotero-standalone-build [submodule "client/zotero-build"] path = client/zotero-build - url = https://github.com/zotero/zotero-build + url = https://github.com/uniuuu/zotero-build + branch = master +[submodule "dataserver"] + path = dataserver + url = https://github.com/uniuuu/dataserver.git + branch = stage diff --git a/COPYING b/LICENSE similarity index 86% rename from COPYING rename to LICENSE index d6194c53..f288702d 100644 --- a/COPYING +++ b/LICENSE @@ -1,37 +1,23 @@ -The Zotero Data Server is Copyright © 2010 Center for History and New Media, -George Mason University, Fairfax, Virginia, USA http://zotero.org + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 -The Center for History and New Media distributes the Zotero Data Server -source code under the GNU Affero General Public License, version 3 (AGPLv3). -The full text of this license is given below. - -The Zotero name is a registered trademark of George Mason University. -See http://zotero.org/trademark for more information. - -Third-party copyright in this distribution is noted where applicable. - -All rights not expressly granted are reserved. - -========================================================================= - - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. + Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. + The GNU General Public License is a free, copyleft license for +software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to +the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free -software for all its users. +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you @@ -40,34 +26,44 @@ them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. @@ -76,7 +72,7 @@ modification follow. 0. Definitions. - "This License" refers to version 3 of the GNU Affero General Public License. + "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. @@ -553,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. + 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single +under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General +Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published +GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's +versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. @@ -649,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) 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 + it under the terms of the GNU 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. + GNU 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 . + You should have received a copy of the GNU General Public License + along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index c44cfa35..ce1b6b0c 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,62 @@ -# ZotPrime - On-premise Zotero platform +# ZotPrime V2 - On-premise Zotero platform ZotPrime is a full packaged repository aimed to make on-premise [Zotero](https://www.zotero.org) deployment easier with the last versions of both Zotero client and server. This is the result of sleepness nights spent to deploy Zotero within my organization on a disconnected network. Feel free to open issues or pull requests if you did not manage to use it. -## Server installation +Table of contents +================= -### Dependencies and source code + * [Localhost and VM Installation](#localhost-and-vm-installation) + * [GKE Installation](#gke-installation) + * [MicroK8s Installation](#microk8s-installation) + * [Client Build](#client-build) -*Install dependencies for client build*: +## Localhost and VM installation + +#### Dependencies and source code + +*Install latest docker compose plugin*: ```bash -$ sudo apt install npm +$ sudo apt update +$ sudo apt install docker-compose-plugin ``` - *Clone the repository (with **--recursive**)*: ```bash $ mkdir /path/to/your/app && cd /path/to/your/app $ git clone --recursive https://github.com/SamuelHassine/zotero-prime.git $ cd zotero-prime ``` - -*Configure and run*: +*Configure*: ```bash -$ cd docker -$ sudo docker-compose up -d +$ cp .env_example .env +$ cp docker-compose-prod.yml docker-compose.yml ``` - -### Initialize databases - +**Edit .env and change DSHOST.** +For Localhost Installation: DSHOST=http://localhost:8080/ +Fot VM Installation: DSHOST=http://\:8080/ +*Run*: +```bash +$ cp .env_example .env +$ cp docker-compose-prod.yml docker-compose.yml +``` +edit .env and add strong passwords +```bash +$ sudo docker compose up -d +``` +#### Initialize databases *Initialize databases*: ```bash $ ./bin/init.sh $ cd .. ``` - *Available endpoints*: | Name | URL | | ------------- | --------------------------------------------- | -| Zotero API | http://localhost:8080 | -| S3 Web UI | http://localhost:8082 | -| PHPMyAdmin | http://localhost:8083 | +| Zotero API | http://localhost:8080 or http://\:8080/ | +| S3 | http://localhost:9000 or http://\:9000/ | +| PHPMyAdmin | http://localhost:8083 or http://\:8083/ | +| S3 Web UI | http://localhost:9001 or http://\:9001/ | +| Stream Server | ws://localhost:8081 or ws://\:8081/ | *Default login/password*: @@ -48,32 +66,141 @@ $ cd .. | S3 Web UI | zotero | zoterodocker | | PHPMyAdmin | root | zotero | -## Client installation +## GKE Installation -### Dependencies and source code +*Clone the repository:* +```bash +$ mkdir /path/to/your/app && cd /path/to/your/app +$ git clone https://github.com/uniuuu/zotprime.git +$ git checkout tags/ -b +$ cd zotprime +``` +*Install Google Cloud SDK: https://cloud.google.com/sdk/docs/install* +*Install Terraform: https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli* +*Install Kubectl: https://kubernetes.io/docs/tasks/tools/install-kubectl/* +*Install Helm: https://helm.sh/docs/intro/install/* +*Run*: +```bash +$ gcloud init +$ gcloud iam service-accounts create zotprimeprod +$ gcloud projects list +$ gcloud projects add-iam-policy-binding --member="serviceAccount:NAME@PROJECT_ID.iam.gserviceaccount.com" --role="roles/owner" +``` +- is name of your project ID +- NAME@PROJECT_ID.iam.gserviceaccount.com is email ID of service account +```bash +$ cd ./zotprime-k8s/GKE/terraform +$ gcloud iam service-accounts keys create cred.json --iam-account=NAME@PROJECT_ID.iam.gserviceaccount.com +$ mv cred.json ./auth/ +$ gcloud services enable container.googleapis.com +$ gcloud services enable cloudresourcemanager.googleapis.com +$ cp terraform.tfvars_example terraform.tfvars +``` +**Edit terraform.tfvars and change project_id, region, zones, node-locations, minnode, maxnode, disksize, machine** +```bash +$ terraform init +$ terraform fmt && terraform validate && terraform plan +$ terraform apply +$ gcloud container clusters get-credentials zotprime-k8s-prod +$ cd .. +``` +**Check cluster and install Zotprime Helm Chart** +```bash +$ kubectl config get-contexts +$ kubectl get all --all-namespaces +``` +**Edit ./helm-chart/values.yaml and change dsuri:, s3Pointuri:, api:, streamserver:, minios3Data:, phpmyadmin:, minios3Web: .** +Replace to your hostnames api (**dsuri:**, **api:**), S3 Minio Data (**s3Pointuri:**, **minios3Data:**), Stream Server (**streamserver:**), Phpmyadmin (**phpmyadmin:**) and S3 Minio Web (**minios3Web:**): +- dsuri: http://api-any.yourhostname.io/ +- s3Pointuri: s3-any.yourhostname.io +- api: api-any.yourhostname.io +- streamserver: stream-any.yourhostname.io +- minios3Data: s3-any.yourhostname.io +- phpmyadmin: phpmyadmin-any.yourhostname.io +- minios3Web: minioweb-any.yourhostname.io +```bash +$ kubectl apply -f zotprime-namespace.yaml +$ helm install zotprime-k8s helm-chart --namespace zotprime +$ kubectl get -A cm,secrets,deploy,rs,sts,pod,pvc,svc,ing +``` +*Get Ingress IP's and setup A records in DNS hosting*: +Wait while GCP will provision IP's verify with below command output in ADDRESS column +```bash +$ kubectl get -A ing +``` +*Available endpoints*: + +| Name | URL | +| ------------- | --------------------------------------------- | +| Zotero API | http://api-any.yourhostname.io | +| S3 | http://s3-any.yourhostname.io | +| PHPMyAdmin | http://phpmyadmin-any.yourhostname.io | +| S3 Web UI | http://minioweb-any.yourhostname.io | +| Stream Server | ws://stream-any.yourhostname.io | + +*Default login/password*: + +| Name | Login | Password | +| ------------- | ------------------------ | ------------------ | +| Zotero API | admin | admin | +| S3 Web UI | zotero | zoterodocker | +| PHPMyAdmin | root | zotero | -For [m|l|w]: m=Mac, w=Windows, l=Linux +## MicroK8s Installation + + +## Client Build + +#### Client build Windows and Linux +*Edit and run*: +- For Localhost Install argument's are: + ``` + HOST_DS=http://localhost:8080/ + HOST_ST=ws://localhost:9000/ + ``` +- For VM Install argument's are: + ``` + HOST_DS=http://:8080/ + HOST_ST=ws:// diff --git a/admin/storage_existing_files b/admin/storage_existing_files deleted file mode 100755 index a195e347..00000000 --- a/admin/storage_existing_files +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/php -set_charset('utf8'); - -$redis = Z_Redis::get(); - -// Empty storageFilesExisting table if starting at the beginning -if (!$startShardID) { - echo "Emptying storageFilesExisting\n"; - sleep(2); - Zotero_Admin_DB::query("TRUNCATE TABLE storageFilesExisting"); - - echo "Emptying " . SET_NAME_EXISTING . "\n"; - $redis->del(SET_NAME_EXISTING); -} - -$maxInsertGroups = 150; - -// Get a list of all used files on all shards and add to a table on master -$shardIDs = Zotero_DB::columnQuery("SELECT shardID FROM shards S WHERE shardID>=? ORDER BY shardID", array($startShardID)); -foreach ($shardIDs as $shardID) { - echo "Shard: $shardID\n"; - - $sql = "SELECT DISTINCT storageFileID FROM storageFileItems"; - $ids = Zotero_DB::columnQuery($sql, false, $shardID); - - $inserted = 0; - $origInsertSQL = "INSERT IGNORE INTO storageFilesExisting VALUES "; - - while ($insertIDs = array_splice($ids, 0, $maxInsertGroups)) { - $num = sizeOf($insertIDs); - $insertSQL = $origInsertSQL . implode(',', array_fill(0, $num, '(?)')); - $stmt = Zotero_DB::getStatement($insertSQL, true); - Zotero_DB::queryFromStatement($stmt, $insertIDs); - $inserted += $num; - } - - echo "Inserted " . $inserted . " storageFileIDs\n"; -} - -// -// Join against storageFiles to get the S3 key names -// -$info = Zotero_DBConnectAuth('master'); -$masterDB = new mysqli($info['host'], $info['user'], $info['pass'], $info['db'], $info['port']); -$masterDB->set_charset('utf8'); - -$sql = "SELECT CONCAT(hash, '/', IF(zip=1, 'c/', ''), filename) AS `key` " - . "FROM storageFilesExisting JOIN storageFiles USING (storageFileID) ORDER BY storageFileID LIMIT "; -$offset = 0; -$prefetch = 50000; -$numRows = 100000; -while (true) { - echo "Getting $numRows rows starting at $offset\n"; - - $stmt = $masterDB->prepare($sql . "$offset, $numRows") or die($masterDB->error); - $stmt->attr_set(MYSQLI_STMT_ATTR_CURSOR_TYPE, MYSQLI_CURSOR_TYPE_READ_ONLY); - $stmt->attr_set(MYSQLI_STMT_ATTR_PREFETCH_ROWS, $prefetch); - $stmt->execute(); - $stmt->bind_result($key); - - // Add keys to redis set - $i = 0; - $arr = [SET_NAME_EXISTING]; - while ($stmt->fetch()) { - $arr[] = $key; - $i++; - if ($i % 50 == 0) { - call_user_func_array([$redis, "sadd"], $arr); - $arr = [SET_NAME_EXISTING]; - } - } - if (sizeOf($arr) > 1) { - call_user_func_array([$redis, "sadd"], $arr); - } - $stmt->close(); - - if ($i == 0) { - break; - } - - $offset += $numRows; -} - -// Join against storageFiles to get the S3 key names -$sql = "SELECT CONCAT(hash, '/', IF(zip=1, 'c/', ''), filename) AS `key` " - . "FROM storageFilesExisting JOIN storageFiles USING (storageFileID) ORDER BY storageFileID"; -$stmt = $masterDB->prepare($sql) or die($masterDB->error); -$stmt->attr_set(MYSQLI_STMT_ATTR_CURSOR_TYPE, MYSQLI_CURSOR_TYPE_READ_ONLY); -$stmt->attr_set(MYSQLI_STMT_ATTR_PREFETCH_ROWS, 50000); -$stmt->execute(); -$stmt->bind_result($key); - -// Add keys to redis set -$i = 0; -$arr = [SET_NAME_EXISTING]; -while ($stmt->fetch()) { - $arr[] = $key; - $i++; - if ($i % 50 == 0) { - call_user_func_array([$redis, "sadd"], $arr); - $arr = [SET_NAME_EXISTING]; - } -} -if (sizeOf($arr) > 1) { - call_user_func_array([$redis, "sadd"], $arr); -} -$stmt->close(); - -$masterDB->close(); -?> diff --git a/admin/storage_purge b/admin/storage_purge deleted file mode 100755 index 9f36f996..00000000 --- a/admin/storage_purge +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/php -srandmember(SET_NAME_EXISTING)) { - die(SET_NAME_EXISTING . " is empty!\n"); -} - -// Clear Redis table -$redis->del(SET_NAME_S3); -$redis->del(SET_NAME_DIFF); - -echo "Listing objects on S3\n"; - -// Get iterator for all files in S3 -$s3 = Z_Core::$AWS->get('s3'); -$options = [ - 'Bucket' => Z_CONFIG::$S3_BUCKET -]; -if ($startKey) { - $options['Marker'] = $startKey; -} -$iterator = $s3->getIterator('ListObjects', $options); - -$deleted = 0; -$ignoredS3 = 0; -$ignoredDB = 0; -$i = 0; -$arr = [SET_NAME_S3]; - -foreach ($iterator as $object) { - $key = $object['Key']; - $lastModified = $object['LastModified']; - - $date = DateTime::createFromFormat('Y-m-d\TH:i:s.uP', $lastModified); - if (!$date) { - die("Invalid date '$lastModified'\n"); - } - - // If file was modified recently on S3, ignore it - if ($date->getTimestamp() > MAX_TIME) { - $ignoredS3++; - continue; - } - - // Otherwise, add to Redis - $arr[] = $key; - $i++; - if ($i % 50000 == 0) { - call_user_func_array([$redis, "sadd"], $arr); - $result = deleteFromS3(); - $deleted += $result['deleted']; - $ignoredDB += $result['ignoredDB']; - $arr = [SET_NAME_S3]; - - echo "Deleted: " . number_format($deleted) . "\n"; - echo "Ignored (S3): " . number_format($ignoredS3) . "\n"; - echo "Ignored (DB): " . number_format($ignoredDB) . "\n"; - echo "Index is $i\n"; - sleep(1); - } -} -if (sizeOf($arr) > 1) { - call_user_func_array([$redis, "sadd"], $arr); - $result = deleteFromS3(); - $deleted += $result['deleted']; - $ignoredDB += $result['ignoredDB']; -} - -function deleteFromS3() { - $s3 = Z_Core::$AWS->get('s3'); - $redis = Z_Redis::get(); - - $deleted = 0; - $ignoredDB = 0; - - // Get keys in S3 that don't exist in shards and store in new set - $redis->sdiffstore(SET_NAME_DIFF, SET_NAME_S3, SET_NAME_EXISTING); - - $deleteStatement = Zotero_DB::getStatement("DELETE FROM storageFiles WHERE storageFileID=?"); - - // TODO: Use SSCAN when available in php-redis - $i = 0; - $toDelete = []; - while ($key = $redis->spop(SET_NAME_DIFF)) { - // See if file was marked as used recently in the database. The modification time in S3 - // isn't updated when an existing file is reused, so this is common. - $info = new Zotero_StorageFileInfo; - try { - $info->parseFromS3Key($key); - } - // Ignore other files ('bookmarklet_upload.html') - catch (Exception $e) { - continue; - } - - $info = Zotero_Storage::getLocalFileInfo($info); - if ($info) { - $lastAdded = DateTime::createFromFormat('Y-m-d H:i:s', $info['lastAdded']); - if ($lastAdded->getTimestamp() > MAX_TIME) { - //echo "$key was modified in storageFiles ({$info['lastAdded']}) -- skipping\n"; - $ignoredDB++; - continue; - } - echo "$key not added recently -- deleting from DB and S3\n"; - if (!DRY_RUN) { - Zotero_DB::queryFromStatement($deleteStatement, $info['storageFileID']); - } - } - else { - echo "$key doesn't exist in DB -- deleting from S3\n"; - } - - $deleted++; - - $toDelete[] = $key; - - $i++; - if ($i % 500 == 0) { - $options = [ - 'Bucket' => Z_CONFIG::$S3_BUCKET, - 'Objects' => array_map(function ($val) { - return ['Key' => $val]; - }, $toDelete) - ]; - echo "Deleting " . sizeOf($options['Objects']) . " files\n"; - if (DRY_RUN) { - echo json_encode($options) . "\n"; - } - else { - $s3->deleteObjects($options); - } - $toDelete = []; - } - } - - if (sizeOf($toDelete)) { - $options = [ - 'Bucket' => Z_CONFIG::$S3_BUCKET, - 'Objects' => array_map(function ($val) { - return ['Key' => $val]; - }, $toDelete) - ]; - echo "Deleting " . sizeOf($options['Objects']) . " files\n"; - if (DRY_RUN) { - echo json_encode($options) . "\n"; - } - else { - $s3->deleteObjects($options); - } - } - - $redis->del(SET_NAME_S3); - $redis->del(SET_NAME_DIFF); - - return [ - 'deleted' => $deleted, - 'ignoredDB' => $ignoredDB - ]; -} - -echo "===============================\n"; -echo "Deleted " . number_format($deleted) . "\n"; -echo "Ignored due to S3 timestamp: " . number_format($ignoredS3) . "\n"; -echo "Ignored due to storageFiles: " . number_format($ignoredDB) . "\n"; diff --git a/admin/update_relations b/admin/update_relations deleted file mode 100755 index adaebc46..00000000 --- a/admin/update_relations +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/php -=? ORDER BY shardID", array($argv[1], $startShardID)); - -foreach ($shardIDs as $shardID) { - echo "Shard: $shardID\n"; - - $i = 0; - $changed = 0; - - while (true) { - $sql = "SELECT relationID, libraryID, subject, predicate, object, serverDateModified FROM relations LIMIT $i, 1000"; - $rows = Zotero_DB::query($sql, false, $shardID); - if (!$rows) { - break; - } - - foreach ($rows as $row) { - $i++; - - if (strpos($row['subject'], "users/local") !== false) { - $userID = Zotero_Users::getUserIDFromLibraryID($row['libraryID']); - $sql = "UPDATE relations SET subject=? WHERE relationID=?"; - $row['subject'] = preg_replace("'users/local/[a-zA-Z0-9]+/'", "users/$userID/", $row['subject']); - try { - Zotero_DB::query($sql, array($row['subject'], $row['relationID']), $shardID); - } - catch (Exception $e) { - echo "Deleting duplicate relation\n"; - $sql = "DELETE FROM relations WHERE relationID=?"; - Zotero_DB::query($sql, array($row['relationID']), $shardID); - continue; - } - } - - if (strpos($row['object'], "users/local") !== false) { - $userID = Zotero_Users::getUserIDFromLibraryID($row['libraryID']); - $sql = "UPDATE relations SET object=? WHERE relationID=?"; - $row['object'] = preg_replace("'users/local/[a-zA-Z0-9]+/'", "users/$userID/", $row['object']); - try { - Zotero_DB::query($sql, array($row['subject'], $row['relationID']), $shardID); - } - catch (Exception $e) { - echo "Deleting duplicate relation\n"; - $sql = "DELETE FROM relations WHERE relationID=?"; - Zotero_DB::query($sql, array($row['relationID']), $shardID); - continue; - } - } - - try { - $subjectLibraryID = Zotero_URI::getURILibrary($row['subject']); - if (!$subjectLibraryID) { - //echo "Library for subject {$row['subject']} doesn't exist\n"; - continue; - } - $subjectLibraryType = Zotero_Libraries::getType($subjectLibraryID); - - $objectLibraryID = Zotero_URI::getURILibrary($row['object']); - if (!$objectLibraryID) { - //echo "Library for object {$row['object']} doesn't exist\n"; - continue; - } - $objectLibraryType = Zotero_Libraries::getType($objectLibraryID); - } - catch (Exception $e) { - if (strpos($e->getMessage(), "Invalid base URI") === 0 || - strpos($e->getMessage(), "Invalid libraryURI URI") === 0) { - echo $e->getMessage() . "\n"; - continue; - } - - throw ($e); - } - - $updateFromSource = false; - $updateFromObject = false; - - if ($row['predicate'] == 'owl:sameAs') { - if ($subjectLibraryType == 'user') { - if ($objectLibraryType == 'group') { - // Store library of user - $updateFromSource = true; - } - } - else if ($subjectLibraryType == 'group') { - if ($objectLibraryType = 'user') { - // Store library of user - $updateFromObject = true; - } - else { - // Store library of source group - $updateFromSource = true; - } - } - } - else if ($row['predicate'] == 'dc:isReplacedBy') { - if ($subjectLibraryType == 'group') { - // Store library of source group - $updateFromSource = true; - } - } - // Other relations - else { - if ($subjectLibraryType == 'group') { - // Store library of source group - $updateFromSource = true; - } - } - - if ($updateFromSource) { - $newLibraryID = $subjectLibraryID; - } - else if ($updateFromObject) { - $newLibraryID = $objectLibraryID; - } - else { - $newLibraryID = false; - } - - if ($newLibraryID) { - if ($newLibraryID == $row['libraryID']) { - continue; - } - - $changed++; - - $newShardID = Zotero_Shards::getByLibraryID($newLibraryID); - // Shard hasn't changed, so just update - if ($shardID == $newShardID) { - $sql = "UPDATE relations SET libraryID=?, serverDateModified=NOW() WHERE relationID=?"; - Zotero_DB::query($sql, array($newLibraryID, $row['relationID']), $shardID); - } - else { - // Add to new shard - $sql = "INSERT INTO relations (relationID, libraryID, subject, predicate, object, serverDateModified) " - . "VALUES (?, ?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE serverDateModified=NOW()"; - Zotero_DB::query($sql, array($row['relationID'], $newLibraryID, $row['subject'], $row['predicate'], $row['object']), $newShardID); - // Remove from old shard - $sql = "DELETE FROM relations WHERE relationID=?"; - Zotero_DB::query($sql, $row['relationID'], $shardID); - } - } - } - - echo "Updated $changed relations rows on shard $shardID\n"; - } -} -?> diff --git a/admin/update_sort_fields b/admin/update_sort_fields deleted file mode 100755 index f65d2d4b..00000000 --- a/admin/update_sort_fields +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/php -=? ORDER BY shardID", array($argv[1], $startShardID)); - -foreach ($shardIDs as $shardID) { - echo "Shard: $shardID\n"; - - $i = 0; - if ($first && $startItemID) { - $lastItemID = $startItemID - 1; - $first = false; - } - else { - $lastItemID = 0; - } - $totalRows = 0; - - while (true) { - $sql = "SELECT I.itemID, I.libraryID, I.key, itemTypeID, - CASE WHEN itemTypeID=1 THEN title ELSE value END AS title - FROM items I "; - if (!$overwrite) { - $sql .= "LEFT JOIN itemSortFields ISF USING (itemID) "; - } - $sql .= "LEFT JOIN itemData ID ON (ID.itemID=I.itemID AND fieldID BETWEEN 110 AND 113) - LEFT JOIN itemNotes INo ON (INo.itemID=I.itemID) - WHERE "; - if (!$overwrite) { - $sql .= "ISF.itemID IS NULL AND "; - } - $sql .= "I.itemID>? ORDER BY I.itemID LIMIT 150"; - $rows = Zotero_DB::query($sql, array($lastItemID), $shardID); - if (!$rows) { - break; - } - - Zotero_DB::beginTransaction(); - - foreach ($rows as $row) { - // No creators for item notes or attachments - if ($row['itemTypeID'] == 1 || $row['itemTypeID'] == 14) { - $sortTitle = Zotero_Items::getSortTitle($row['title']); - if (mb_substr($sortTitle, 0, 5) == mb_substr($row['title'], 0, 5)) { - $sortTitle = null; - } - $creatorSummary = ''; - - if ($dryrun) { - var_dump('---------'); - var_dump($row['itemID']); - if ($sortTitle) { - var_dump($row['title']); - } - var_dump($sortTitle); - var_dump($creatorSummary); - } - else { - $sql = "INSERT INTO itemSortFields (itemID, sortTitle, creatorSummary) VALUES (?, ?, ?) - ON DUPLICATE KEY UPDATE sortTitle=?, creatorSummary=?"; - Zotero_DB::query($sql, array($row['itemID'], $sortTitle, $creatorSummary, $sortTitle, $creatorSummary), $shardID); - } - } - else { - $item = Zotero_Items::getByLibraryAndKey($row['libraryID'], $row['key']); - if (!$item) { - throw new Exception("Item {$row['libraryID']}/{$row['key']} not found"); - } - - $sortTitle = Zotero_Items::getSortTitle($item->getDisplayTitle(true)); - if (mb_substr($sortTitle, 0, 5) == mb_substr($item->getField('title', false, true), 0, 5)) { - $sortTitle = null; - } - $creatorSummary = $item->isRegularItem() ? mb_strcut($item->creatorSummary, 0, Zotero_Creators::$creatorSummarySortLength) : ''; - - if ($dryrun) { - var_dump('========'); - var_dump($row['itemID']); - if ($sortTitle) { - var_dump($item->getDisplayTitle(true)); - } - var_dump($sortTitle); - var_dump($creatorSummary); - } - else { - $sql = "INSERT INTO itemSortFields (itemID, sortTitle, creatorSummary) VALUES (?, ?, ?) - ON DUPLICATE KEY UPDATE sortTitle=?, creatorSummary=?"; - Zotero_DB::query($sql, array($row['itemID'], $sortTitle, $creatorSummary, $sortTitle, $creatorSummary), $shardID); - } - } - - if ($i != 0 && $i % 1500 == 0) { - echo "................\n"; - } - - $i++; - $totalRows++; - } - - $lastItemID = $row['itemID']; - - Zotero_DB::commit(); - - echo "Updated $totalRows itemSortFields rows on shard $shardID at $lastItemID\n"; - } -} -?> diff --git a/docker/bin/create-user.sh b/bin/create-user.sh similarity index 56% rename from docker/bin/create-user.sh rename to bin/create-user.sh index 0a088d6a..03edc0c5 100755 --- a/docker/bin/create-user.sh +++ b/bin/create-user.sh @@ -5,4 +5,4 @@ if [ -z "$1" -o -z "$2" -o -z "$3" ]; then exit 1 fi -sudo docker-compose exec app-zotero /var/www/zotero/admin/create-user.sh ${1} ${2} ${3} +sudo docker compose exec zotprime-dataserver /var/www/zotero/admin/create-user.sh ${1} ${2} ${3} diff --git a/bin/init-k8s.sh b/bin/init-k8s.sh new file mode 100755 index 00000000..d8b60bcc --- /dev/null +++ b/bin/init-k8s.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -eux + +kubectl -n zotprime exec -it $(kubectl -n zotprime get pods -l apps=zotprime-dataserver -o custom-columns=:metadata.name) -- sh -cux 'cd /var/www/zotero/misc && ./init-mysql.sh' +kubectl -n zotprime exec -it $(kubectl -n zotprime get pods -l apps=zotprime-dataserver -o custom-columns=:metadata.name) -- sh -cux 'cd /var/www/zotero/misc && ./db_update.sh' +kubectl -n zotprime exec -it $(kubectl -n zotprime get pods -l apps=zotprime-dataserver -o custom-columns=:metadata.name) -- sh -cux 'aws --endpoint-url "http://minio:9000" s3 mb s3://zotero' +kubectl -n zotprime exec -it $(kubectl -n zotprime get pods -l apps=zotprime-dataserver -o custom-columns=:metadata.name) -- sh -cux 'aws --endpoint-url "http://minio:9000" s3 mb s3://zotero-fulltext' +kubectl -n zotprime exec -it $(kubectl -n zotprime get pods -l apps=zotprime-dataserver -o custom-columns=:metadata.name) -- sh -cux 'aws --endpoint-url "http://localstack:4575" sns create-topic --name zotero' diff --git a/bin/init-microk8s.sh b/bin/init-microk8s.sh new file mode 100755 index 00000000..b9c827b1 --- /dev/null +++ b/bin/init-microk8s.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -eux + +microk8s kubectl -n zotprime exec -it $(microk8s kubectl -n zotprime get pods -l apps=zotprime-dataserver -o custom-columns=:metadata.name) -- sh -cux 'cd /var/www/zotero/misc && ./init-mysql.sh' +microk8s kubectl -n zotprime exec -it $(microk8s kubectl -n zotprime get pods -l apps=zotprime-dataserver -o custom-columns=:metadata.name) -- sh -cux 'cd /var/www/zotero/misc && ./db_update.sh' +microk8s kubectl -n zotprime exec -it $(microk8s kubectl -n zotprime get pods -l apps=zotprime-dataserver -o custom-columns=:metadata.name) -- sh -cux 'aws --endpoint-url "http://minio:9000" s3 mb s3://zotero' +microk8s kubectl -n zotprime exec -it $(microk8s kubectl -n zotprime get pods -l apps=zotprime-dataserver -o custom-columns=:metadata.name) -- sh -cux 'aws --endpoint-url "http://minio:9000" s3 mb s3://zotero-fulltext' +microk8s kubectl -n zotprime exec -it $(microk8s kubectl -n zotprime get pods -l apps=zotprime-dataserver -o custom-columns=:metadata.name) -- sh -cux 'aws --endpoint-url "http://localstack:4575" sns create-topic --name zotero' diff --git a/bin/init.sh b/bin/init.sh new file mode 100755 index 00000000..c73b1db7 --- /dev/null +++ b/bin/init.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -eux + +sudo docker compose exec zotprime-dataserver sh -cux 'cd /var/www/zotero/misc && ./init-mysql.sh' +sudo docker compose exec zotprime-dataserver sh -cux 'cd /var/www/zotero/misc && ./db_update.sh' +sudo docker compose exec zotprime-dataserver sh -cux 'aws --endpoint-url "http://minio:9000" s3 mb s3://zotero' +sudo docker compose exec zotprime-dataserver sh -cux 'aws --endpoint-url "http://minio:9000" s3 mb s3://zotero-fulltext' +sudo docker compose exec zotprime-dataserver sh -cux 'aws --endpoint-url "http://localstack:4575" sns create-topic --name zotero' diff --git a/bin/run.sh b/bin/run.sh new file mode 100755 index 00000000..ff0ab919 --- /dev/null +++ b/bin/run.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +sudo docker compose up diff --git a/client.Dockerfile b/client.Dockerfile new file mode 100644 index 00000000..215d5df3 --- /dev/null +++ b/client.Dockerfile @@ -0,0 +1,72 @@ + +FROM node:16-alpine as intermediate +ARG ZOTPRIME_VERSION=2 + +RUN set -eux; \ + apk update && apk upgrade && \ + apk add --update --no-cache git bash curl python3 zip perl rsync 7zip \ +# python2 \ +# util-linux \ + && rm -rf /var/cache/apk/* +WORKDIR /usr/src/app +COPY . . + +RUN git submodule update --init client/zotero-build +RUN git submodule update --init client/zotero-standalone-build +RUN git submodule update --init client/zotero-client + + + +WORKDIR /usr/src/app/client/zotero-client +#RUN git checkout tags/6.0.23 -b v6.0.23 +RUN git checkout tags/6.0.26 -b v6.0.26 +RUN rm -rf * +RUN git checkout -- . +RUN git submodule update --init --recursive + +WORKDIR /usr/src/app/client/zotero-standalone-build +RUN git checkout tags/6.0.23 -b v6.0.23 +RUN rm -rf * +RUN git checkout -- . +RUN git submodule update --init --recursive + +WORKDIR /usr/src/app/client/zotero-build +#RUN git checkout 00e854c6588f329b714250e450f4f7f663aa0222 +RUN git checkout tags/apr20 -b v_apr20 +RUN git status +RUN git submodule update --init --recursive + +#WORKDIR /usr/src/app + +#RUN git submodule update --init --recursive client/zotero-build +#RUN git submodule update --init --recursive client/zotero-standalone-build +#RUN git submodule update --init --recursive client/zotero-client + +WORKDIR /usr/src/app/client/ +#ARG CONFIG=config.sh +ARG HOST_DS=http://localhost:8080/ +ARG HOST_ST=ws://localhost:9000/ +RUN set -eux; \ + sed -i "s#https://api.zotero.org/#$HOST_DS#g" zotero-client/resource/config.js; \ + sed -i "s#wss://stream.zotero.org/#$HOST_ST#g" zotero-client/resource/config.js; \ + sed -i "s#https://www.zotero.org/#$HOST_DS#g" zotero-client/resource/config.js; \ + sed -i "s#https://zoteroproxycheck.s3.amazonaws.com/test##g" zotero-client/resource/config.js +# ./$CONFIG +WORKDIR /usr/src/app/client/zotero-client +#RUN set -eux; \ +# npx browserslist@latest --update-db --legacy-peer-deps +ARG MLW=l +RUN set -eux; \ + npm install --legacy-peer-deps +RUN set -eux; \ + npm run build +WORKDIR /usr/src/app/client/zotero-standalone-build +RUN set -eux; \ + /bin/bash -c "./fetch_xulrunner.sh -p $MLW" +RUN set -eux; \ + ./fetch_pdftools +RUN set -eux; \ + ./scripts/dir_build -p $MLW + +FROM scratch AS export-stage +COPY --from=intermediate /usr/src/app/client/zotero-standalone-build/staging . \ No newline at end of file diff --git a/client.Dockerfile.dockerignore b/client.Dockerfile.dockerignore new file mode 100644 index 00000000..55151f2a --- /dev/null +++ b/client.Dockerfile.dockerignore @@ -0,0 +1,34 @@ +clinet/zotero-clinet/.DS_Store +clinet/zotero-clinet/node_modules +clinet/zotero-clinet/build +clinet/zotero-clinet/.signatures.json +clinet/zotero-clinet/tmp +clinet/zotero-build/config.sh +clinet/zotero-build/node_modules +clinet/zotero-standalone-build/*~ +clinet/zotero-standalone-build/cache +clinet/zotero-standalone-build/config-custom.sh +clinet/zotero-standalone-build/dist +clinet/zotero-standalone-build/staging +clinet/zotero-standalone-build/xulrunner +clinet/zotero-standalone-build/pdftools +**/secret.json +**/secret.txt +**/secret.yaml +.env +*.Dockerfile +.github +.vscode/ +bin +build +dataserver +doc +docker +docker-compose.yml +logs +README.md +stream-server +tinymce-clean-server +Zend +zotprime-k8s + diff --git a/client/config-k8s.sh b/client/config-k8s.sh new file mode 100755 index 00000000..708744b7 --- /dev/null +++ b/client/config-k8s.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +set -eux + + +#sed -i "s#'http://zotero.org/'#'http://api.projectdev.net/'#g" zotero-client/resource/config.js +#sed -i "s#'zotero.org'#'api.projectdev.net'#g" zotero-client/resource/config.js +sed -i "s#https://api.zotero.org/#http://api.projectdev.net/#g" zotero-client/resource/config.js +sed -i "s#wss://stream.zotero.org/#ws://stream.projectdev.net/#g" zotero-client/resource/config.js +sed -i "s#https://www.zotero.org/#http://api.projectdev.net/#g" zotero-client/resource/config.js +sed -i "s#https://zoteroproxycheck.s3.amazonaws.com/test##g" zotero-client/resource/config.js + + + diff --git a/client/config-microk8s.sh b/client/config-microk8s.sh new file mode 100755 index 00000000..bd2ee347 --- /dev/null +++ b/client/config-microk8s.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +set -eux + + +#sed -i "s#'http://zotero.org/'#'http://api.zotprime:8080/'#g" zotero-client/resource/config.js +#sed -i "s#'zotero.org'#'api.zotprime'#g" zotero-client/resource/config.js +sed -i "s#https://api.zotero.org/#http://api.zotprime:8080/#g" zotero-client/resource/config.js +sed -i "s#wss://stream.zotero.org/#ws://stream.zotprime:8081/#g" zotero-client/resource/config.js +sed -i "s#https://www.zotero.org/#http://api.zotprime:8080/#g" zotero-client/resource/config.js +sed -i "s#https://zoteroproxycheck.s3.amazonaws.com/test##g" zotero-client/resource/config.js + + + diff --git a/client/config.sh b/client/config.sh index 957b919e..5ec48d2b 100755 --- a/client/config.sh +++ b/client/config.sh @@ -1,6 +1,33 @@ #!/bin/sh -sed -i 's#https://api.zotero.org/#http://localhost:8080/#g' zotero-client/resource/config.js -sed -i 's#wss://stream.zotero.org/#ws://localhost:8081/#g' zotero-client/resource/config.js -sed -i 's#https://www.zotero.org/#http://localhost:8080/#g' zotero-client/resource/config.js -sed -i 's#https://zoteroproxycheck.s3.amazonaws.com/test##g' zotero-client/resource/config.js +set -e + +HOST=localhost + + +echo -n "Enter the ip/hostaname of the server. Leave empty for default : " +read HOST + +echo -n "Note in case ip/hostname has a typo then to revert back changes run next commands " +echo +read -p "Are you sure you want to continue? " -n 1 -r +echo # (optional) move to a new line +if [[ $REPLY =~ ^[Yy]$ ]] +then + case $HOST in + "") SERVER=localhost ;; + *) SERVER=$HOST ;; + esac + echo "Host is set to $HOST" + + + #sed -i "s#'http://zotero.org/'#'http://$SERVER:8080/'#g" zotero-client/resource/config.js + #sed -i "s#'zotero.org'#'$SERVER'#g" zotero-client/resource/config.js + sed -i "s#https://api.zotero.org/#http://$SERVER:8080/#g" zotero-client/resource/config.js + sed -i "s#wss://stream.zotero.org/#ws://$SERVER:8081/#g" zotero-client/resource/config.js + sed -i "s#https://www.zotero.org/#http://$SERVER:8080/#g" zotero-client/resource/config.js + sed -i "s#https://zoteroproxycheck.s3.amazonaws.com/test##g" zotero-client/resource/config.js + + +fi + diff --git a/client/zotero-build b/client/zotero-build index dae807ab..5cec38cd 160000 --- a/client/zotero-build +++ b/client/zotero-build @@ -1 +1 @@ -Subproject commit dae807ab51df60019071184ecfb80b07b0d29c8b +Subproject commit 5cec38cd40361d939e32eb0b6e0fd18ac7b78a56 diff --git a/client/zotero-client b/client/zotero-client index 91bac0e0..f68dc7c9 160000 --- a/client/zotero-client +++ b/client/zotero-client @@ -1 +1 @@ -Subproject commit 91bac0e05239bc1a9f63b2977e707c8bba4197f4 +Subproject commit f68dc7c90d68efaa6d1b16e1524cfd78a052bd38 diff --git a/client/zotero-standalone-build b/client/zotero-standalone-build index 93f5c067..ea60c6de 160000 --- a/client/zotero-standalone-build +++ b/client/zotero-standalone-build @@ -1 +1 @@ -Subproject commit 93f5c0674836100bcac4f12798b337d018787f02 +Subproject commit ea60c6deb021f46f5396ea752e8da772b7350a72 diff --git a/clientbuildtest.Dockerfile b/clientbuildtest.Dockerfile new file mode 100644 index 00000000..c79aca08 --- /dev/null +++ b/clientbuildtest.Dockerfile @@ -0,0 +1,43 @@ + +FROM node:16-alpine as intermediate +ARG ZOTPRIME_VERSION=2 + +RUN set -eux; \ + apk update && apk upgrade && \ + apk add --update --no-cache git bash curl python3 zip perl rsync \ + && rm -rf /var/cache/apk/* +WORKDIR /usr/src/app +RUN mkdir client +RUN cd client +RUN git init +RUN git remote add -f origin https://github.com/uniuuu/zotprime +RUN echo "client/" >> .git/info/sparse-checkout +RUN git pull origin development +RUN git submodule update --init --recursive +WORKDIR /usr/src/app/client/ +RUN set -eux; \ + sed -i "s#'http://zotero.org/'#'http://localhost:8080/'#g" zotero-client/resource/config.js; \ + sed -i "s#'zotero.org'#'localhost'#g" zotero-client/resource/config.js; \ + sed -i "s#https://api.zotero.org/#http://localhost:8080/#g" zotero-client/resource/config.js; \ + sed -i "s#wss://stream.zotero.org/#ws://localhost:8081/#g" zotero-client/resource/config.js; \ + sed -i "s#https://www.zotero.org/#http://localhost:8080/#g" zotero-client/resource/config.js; \ + sed -i "s#https://zoteroproxycheck.s3.amazonaws.com/test##g" zotero-client/resource/config.js +# ./config.sh +WORKDIR /usr/src/app/client/zotero-client +RUN set -eux; \ + npx browserslist@latest --update-db +RUN set -eux; \ + npm install +RUN set -eux; \ + npm run build +WORKDIR /usr/src/app/client/zotero-standalone-build +RUN set -eux; \ + ./fetch_xulrunner.sh -p l +RUN set -eux; \ + ./fetch_pdftools +RUN set -eux; \ + ./scripts/dir_build -p l + + +FROM scratch AS export-stage +COPY --from=intermediate /usr/src/app/client/zotero-standalone-build/staging/* . \ No newline at end of file diff --git a/clientbuildtest.Dockerfile.dockerignore b/clientbuildtest.Dockerfile.dockerignore new file mode 100644 index 00000000..6d7229a1 --- /dev/null +++ b/clientbuildtest.Dockerfile.dockerignore @@ -0,0 +1,18 @@ +**/secret.json +**/secret.txt +**/secret.yaml +.env +.github +.vscode/ +bin +build +dataserver +doc +docker +docker-compose.yml +logs +stream-server +tinymce-clean-server +Zend +zotprime-k8s + diff --git a/composer.json b/composer.json deleted file mode 100644 index 7c6c661a..00000000 --- a/composer.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "zotero/dataserver", - "description": "Zotero Sync and API Server", - "license": "AGPL-3.0", - "require": { - "aws/aws-sdk-php": "^3.2", - "doctrine/cache": "1.*" - }, - "require-dev": { - "phpunit/phpunit": "^4.8" - } -} diff --git a/composer.lock b/composer.lock deleted file mode 100644 index fc9a13fb..00000000 --- a/composer.lock +++ /dev/null @@ -1,1393 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", - "This file is @generated automatically" - ], - "hash": "7f2e24c78eddfaa3636ce650150f21b6", - "content-hash": "4df91e2f1cfeac7125942e9e4ace41d2", - "packages": [ - { - "name": "aws/aws-sdk-php", - "version": "3.9.2", - "source": { - "type": "git", - "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "a06a225abbaf465d1640305c85f7c830e3c43ecf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a06a225abbaf465d1640305c85f7c830e3c43ecf", - "reference": "a06a225abbaf465d1640305c85f7c830e3c43ecf", - "shasum": "" - }, - "require": { - "guzzlehttp/guzzle": "~5.3|~6.0.1|~6.1", - "guzzlehttp/promises": "~1.0", - "guzzlehttp/psr7": "~1.0", - "mtdowling/jmespath.php": "~2.2", - "php": ">=5.5" - }, - "require-dev": { - "aws/aws-php-sns-message-validator": "~1.0", - "behat/behat": "~3.0", - "doctrine/cache": "~1.4", - "ext-dom": "*", - "ext-json": "*", - "ext-openssl": "*", - "ext-pcre": "*", - "ext-simplexml": "*", - "ext-spl": "*", - "nette/neon": "^2.3", - "phpunit/phpunit": "~4.0" - }, - "suggest": { - "doctrine/cache": "To use the DoctrineCacheAdapter", - "ext-curl": "To send requests using cURL", - "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, - "autoload": { - "psr-4": { - "Aws\\": "src/" - }, - "files": [ - "src/functions.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Amazon Web Services", - "homepage": "http://aws.amazon.com" - } - ], - "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", - "homepage": "http://aws.amazon.com/sdkforphp", - "keywords": [ - "amazon", - "aws", - "cloud", - "dynamodb", - "ec2", - "glacier", - "s3", - "sdk" - ], - "time": "2015-10-29 20:17:13" - }, - { - "name": "doctrine/cache", - "version": "v1.5.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/cache.git", - "reference": "eb8a73619af4f1c8711e2ce482f5de3643258a1f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/eb8a73619af4f1c8711e2ce482f5de3643258a1f", - "reference": "eb8a73619af4f1c8711e2ce482f5de3643258a1f", - "shasum": "" - }, - "require": { - "php": ">=5.3.2" - }, - "conflict": { - "doctrine/common": ">2.2,<2.4" - }, - "require-dev": { - "phpunit/phpunit": ">=3.7", - "predis/predis": "~1.0", - "satooshi/php-coveralls": "~0.6" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.5.x-dev" - } - }, - "autoload": { - "psr-4": { - "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "Caching library offering an object-oriented API for many cache backends", - "homepage": "http://www.doctrine-project.org", - "keywords": [ - "cache", - "caching" - ], - "time": "2015-10-28 11:27:45" - }, - { - "name": "guzzlehttp/guzzle", - "version": "6.1.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/guzzle.git", - "reference": "66fd14b4d0b8f2389eaf37c5458608c7cb793a81" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/66fd14b4d0b8f2389eaf37c5458608c7cb793a81", - "reference": "66fd14b4d0b8f2389eaf37c5458608c7cb793a81", - "shasum": "" - }, - "require": { - "guzzlehttp/promises": "~1.0", - "guzzlehttp/psr7": "~1.1", - "php": ">=5.5.0" - }, - "require-dev": { - "ext-curl": "*", - "phpunit/phpunit": "~4.0", - "psr/log": "~1.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "6.1-dev" - } - }, - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "GuzzleHttp\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - } - ], - "description": "Guzzle is a PHP HTTP client library", - "homepage": "http://guzzlephp.org/", - "keywords": [ - "client", - "curl", - "framework", - "http", - "http client", - "rest", - "web service" - ], - "time": "2015-09-08 17:36:26" - }, - { - "name": "guzzlehttp/promises", - "version": "1.0.3", - "source": { - "type": "git", - "url": "https://github.com/guzzle/promises.git", - "reference": "b1e1c0d55f8083c71eda2c28c12a228d708294ea" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/b1e1c0d55f8083c71eda2c28c12a228d708294ea", - "reference": "b1e1c0d55f8083c71eda2c28c12a228d708294ea", - "shasum": "" - }, - "require": { - "php": ">=5.5.0" - }, - "require-dev": { - "phpunit/phpunit": "~4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Promise\\": "src/" - }, - "files": [ - "src/functions_include.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - } - ], - "description": "Guzzle promises library", - "keywords": [ - "promise" - ], - "time": "2015-10-15 22:28:00" - }, - { - "name": "guzzlehttp/psr7", - "version": "1.2.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "4ef919b0cf3b1989523138b60163bbcb7ba1ff7e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/4ef919b0cf3b1989523138b60163bbcb7ba1ff7e", - "reference": "4ef919b0cf3b1989523138b60163bbcb7ba1ff7e", - "shasum": "" - }, - "require": { - "php": ">=5.4.0", - "psr/http-message": "~1.0" - }, - "provide": { - "psr/http-message-implementation": "1.0" - }, - "require-dev": { - "phpunit/phpunit": "~4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - }, - "files": [ - "src/functions_include.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - } - ], - "description": "PSR-7 message implementation", - "keywords": [ - "http", - "message", - "stream", - "uri" - ], - "time": "2015-08-15 19:32:36" - }, - { - "name": "mtdowling/jmespath.php", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/jmespath/jmespath.php.git", - "reference": "a7d99d0c836e69d27b7bfca1d33ca2759fba3289" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a7d99d0c836e69d27b7bfca1d33ca2759fba3289", - "reference": "a7d99d0c836e69d27b7bfca1d33ca2759fba3289", - "shasum": "" - }, - "require": { - "php": ">=5.4.0" - }, - "require-dev": { - "phpunit/phpunit": "~4.0" - }, - "bin": [ - "bin/jp.php" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, - "autoload": { - "psr-4": { - "JmesPath\\": "src/" - }, - "files": [ - "src/JmesPath.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - } - ], - "description": "Declaratively specify how to extract elements from a JSON document", - "keywords": [ - "json", - "jsonpath" - ], - "time": "2015-05-27 17:21:31" - }, - { - "name": "psr/http-message", - "version": "1.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "85d63699f0dbedb190bbd4b0d2b9dc707ea4c298" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/85d63699f0dbedb190bbd4b0d2b9dc707ea4c298", - "reference": "85d63699f0dbedb190bbd4b0d2b9dc707ea4c298", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP messages", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], - "time": "2015-05-04 20:22:00" - } - ], - "packages-dev": [ - { - "name": "doctrine/instantiator", - "version": "1.0.5", - "source": { - "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", - "shasum": "" - }, - "require": { - "php": ">=5.3,<8.0-DEV" - }, - "require-dev": { - "athletic/athletic": "~0.1.8", - "ext-pdo": "*", - "ext-phar": "*", - "phpunit/phpunit": "~4.0", - "squizlabs/php_codesniffer": "~2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "http://ocramius.github.com/" - } - ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://github.com/doctrine/instantiator", - "keywords": [ - "constructor", - "instantiate" - ], - "time": "2015-06-14 21:17:01" - }, - { - "name": "phpdocumentor/reflection-docblock", - "version": "2.0.4", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/d68dbdc53dc358a816f00b300704702b2eaff7b8", - "reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.0" - }, - "suggest": { - "dflydev/markdown": "~1.0", - "erusev/parsedown": "~1.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-0": { - "phpDocumentor": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "mike.vanriel@naenius.com" - } - ], - "time": "2015-02-03 12:10:50" - }, - { - "name": "phpspec/prophecy", - "version": "v1.5.0", - "source": { - "type": "git", - "url": "https://github.com/phpspec/prophecy.git", - "reference": "4745ded9307786b730d7a60df5cb5a6c43cf95f7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/4745ded9307786b730d7a60df5cb5a6c43cf95f7", - "reference": "4745ded9307786b730d7a60df5cb5a6c43cf95f7", - "shasum": "" - }, - "require": { - "doctrine/instantiator": "^1.0.2", - "phpdocumentor/reflection-docblock": "~2.0", - "sebastian/comparator": "~1.1" - }, - "require-dev": { - "phpspec/phpspec": "~2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4.x-dev" - } - }, - "autoload": { - "psr-0": { - "Prophecy\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Konstantin Kudryashov", - "email": "ever.zet@gmail.com", - "homepage": "http://everzet.com" - }, - { - "name": "Marcello Duarte", - "email": "marcello.duarte@gmail.com" - } - ], - "description": "Highly opinionated mocking framework for PHP 5.3+", - "homepage": "https://github.com/phpspec/prophecy", - "keywords": [ - "Double", - "Dummy", - "fake", - "mock", - "spy", - "stub" - ], - "time": "2015-08-13 10:07:40" - }, - { - "name": "phpunit/php-code-coverage", - "version": "2.2.4", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/eabf68b476ac7d0f73793aada060f1c1a9bf8979", - "reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979", - "shasum": "" - }, - "require": { - "php": ">=5.3.3", - "phpunit/php-file-iterator": "~1.3", - "phpunit/php-text-template": "~1.2", - "phpunit/php-token-stream": "~1.3", - "sebastian/environment": "^1.3.2", - "sebastian/version": "~1.0" - }, - "require-dev": { - "ext-xdebug": ">=2.1.4", - "phpunit/phpunit": "~4" - }, - "suggest": { - "ext-dom": "*", - "ext-xdebug": ">=2.2.1", - "ext-xmlwriter": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.2.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", - "homepage": "https://github.com/sebastianbergmann/php-code-coverage", - "keywords": [ - "coverage", - "testing", - "xunit" - ], - "time": "2015-10-06 15:47:00" - }, - { - "name": "phpunit/php-file-iterator", - "version": "1.4.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6150bf2c35d3fc379e50c7602b75caceaa39dbf0", - "reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", - "keywords": [ - "filesystem", - "iterator" - ], - "time": "2015-06-21 13:08:43" - }, - { - "name": "phpunit/php-text-template", - "version": "1.2.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", - "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", - "keywords": [ - "template" - ], - "time": "2015-06-21 13:50:34" - }, - { - "name": "phpunit/php-timer", - "version": "1.0.7", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3e82f4e9fc92665fafd9157568e4dcb01d014e5b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3e82f4e9fc92665fafd9157568e4dcb01d014e5b", - "reference": "3e82f4e9fc92665fafd9157568e4dcb01d014e5b", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", - "keywords": [ - "timer" - ], - "time": "2015-06-21 08:01:12" - }, - { - "name": "phpunit/php-token-stream", - "version": "1.4.8", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da", - "reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Wrapper around PHP's tokenizer extension.", - "homepage": "https://github.com/sebastianbergmann/php-token-stream/", - "keywords": [ - "tokenizer" - ], - "time": "2015-09-15 10:49:45" - }, - { - "name": "phpunit/phpunit", - "version": "4.8.21", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ea76b17bced0500a28098626b84eda12dbcf119c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ea76b17bced0500a28098626b84eda12dbcf119c", - "reference": "ea76b17bced0500a28098626b84eda12dbcf119c", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-json": "*", - "ext-pcre": "*", - "ext-reflection": "*", - "ext-spl": "*", - "php": ">=5.3.3", - "phpspec/prophecy": "^1.3.1", - "phpunit/php-code-coverage": "~2.1", - "phpunit/php-file-iterator": "~1.4", - "phpunit/php-text-template": "~1.2", - "phpunit/php-timer": ">=1.0.6", - "phpunit/phpunit-mock-objects": "~2.3", - "sebastian/comparator": "~1.1", - "sebastian/diff": "~1.2", - "sebastian/environment": "~1.3", - "sebastian/exporter": "~1.2", - "sebastian/global-state": "~1.0", - "sebastian/version": "~1.0", - "symfony/yaml": "~2.1|~3.0" - }, - "suggest": { - "phpunit/php-invoker": "~1.1" - }, - "bin": [ - "phpunit" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.8.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", - "keywords": [ - "phpunit", - "testing", - "xunit" - ], - "time": "2015-12-12 07:45:58" - }, - { - "name": "phpunit/phpunit-mock-objects", - "version": "2.3.8", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/ac8e7a3db35738d56ee9a76e78a4e03d97628983", - "reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983", - "shasum": "" - }, - "require": { - "doctrine/instantiator": "^1.0.2", - "php": ">=5.3.3", - "phpunit/php-text-template": "~1.2", - "sebastian/exporter": "~1.2" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "suggest": { - "ext-soap": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.3.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "Mock Object library for PHPUnit", - "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", - "keywords": [ - "mock", - "xunit" - ], - "time": "2015-10-02 06:51:40" - }, - { - "name": "sebastian/comparator", - "version": "1.2.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "937efb279bd37a375bcadf584dec0726f84dbf22" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/937efb279bd37a375bcadf584dec0726f84dbf22", - "reference": "937efb279bd37a375bcadf584dec0726f84dbf22", - "shasum": "" - }, - "require": { - "php": ">=5.3.3", - "sebastian/diff": "~1.2", - "sebastian/exporter": "~1.2" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "http://www.github.com/sebastianbergmann/comparator", - "keywords": [ - "comparator", - "compare", - "equality" - ], - "time": "2015-07-26 15:48:44" - }, - { - "name": "sebastian/diff", - "version": "1.4.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "13edfd8706462032c2f52b4b862974dd46b71c9e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/13edfd8706462032c2f52b4b862974dd46b71c9e", - "reference": "13edfd8706462032c2f52b4b862974dd46b71c9e", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.8" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff" - ], - "time": "2015-12-08 07:14:41" - }, - { - "name": "sebastian/environment", - "version": "1.3.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "6e7133793a8e5a5714a551a8324337374be209df" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6e7133793a8e5a5714a551a8324337374be209df", - "reference": "6e7133793a8e5a5714a551a8324337374be209df", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.3.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", - "keywords": [ - "Xdebug", - "environment", - "hhvm" - ], - "time": "2015-12-02 08:37:27" - }, - { - "name": "sebastian/exporter", - "version": "1.2.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "7ae5513327cb536431847bcc0c10edba2701064e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/7ae5513327cb536431847bcc0c10edba2701064e", - "reference": "7ae5513327cb536431847bcc0c10edba2701064e", - "shasum": "" - }, - "require": { - "php": ">=5.3.3", - "sebastian/recursion-context": "~1.0" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - } - ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "http://www.github.com/sebastianbergmann/exporter", - "keywords": [ - "export", - "exporter" - ], - "time": "2015-06-21 07:55:53" - }, - { - "name": "sebastian/global-state", - "version": "1.1.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bc37d50fea7d017d3d340f230811c9f1d7280af4", - "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.2" - }, - "suggest": { - "ext-uopz": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", - "keywords": [ - "global state" - ], - "time": "2015-10-12 03:26:01" - }, - { - "name": "sebastian/recursion-context", - "version": "1.0.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "913401df809e99e4f47b27cdd781f4a258d58791" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/913401df809e99e4f47b27cdd781f4a258d58791", - "reference": "913401df809e99e4f47b27cdd781f4a258d58791", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - } - ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "http://www.github.com/sebastianbergmann/recursion-context", - "time": "2015-11-11 19:50:13" - }, - { - "name": "sebastian/version", - "version": "1.0.6", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", - "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", - "shasum": "" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", - "time": "2015-06-21 13:59:46" - }, - { - "name": "symfony/yaml", - "version": "v3.0.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "3df409958a646dad2bc5046c3fb671ee24a1a691" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/3df409958a646dad2bc5046c3fb671ee24a1a691", - "reference": "3df409958a646dad2bc5046c3fb671ee24a1a691", - "shasum": "" - }, - "require": { - "php": ">=5.5.9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony Yaml Component", - "homepage": "https://symfony.com", - "time": "2015-12-26 13:39:53" - } - ], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": [], - "prefer-stable": false, - "prefer-lowest": false, - "platform": [], - "platform-dev": [] -} diff --git a/controllers/ApiController.php b/controllers/ApiController.php deleted file mode 100644 index e63f2801..00000000 --- a/controllers/ApiController.php +++ /dev/null @@ -1,1367 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class ApiController extends Controller { - protected $writeTokenCacheTime = 43200; // 12 hours - - private $profile = false; - private $timeLogThreshold = 5; - - protected $apiVersion; - protected $method; - protected $uri; - protected $queryParams = array(); - protected $ifUnmodifiedSince; - protected $body; - protected $apiKey; - protected $responseXML; - protected $responseCode = 200; - protected $userID; // request user - protected $permissions; - protected $objectUserID; // userID of object owner - protected $objectGroupID; // groupID of object owner - protected $objectLibraryID; // libraryID of object owner - protected $objectGlobalItemID; - protected $scopeObject; - protected $scopeObjectID; - protected $scopeObjectKey; - protected $scopeObjectName; - protected $objectID; - protected $objectKey; - protected $objectName; - protected $subset; - protected $singleObject; - protected $publications = false; - protected $legacyPublications = false; - protected $fileMode; - protected $fileView; - protected $httpAuth = false; - protected $cookieAuth = false; - protected $libraryVersion; - protected $libraryVersionOnFailure = false; - protected $headers = []; - - private $startTime = false; - private $timeLogged = false; - - - public function init($extra) { - $this->startTime = microtime(true); - - if (!Z_CONFIG::$API_ENABLED) { - $this->e503(Z_CONFIG::$MAINTENANCE_MESSAGE); - } - - if (!empty(Z_CONFIG::$BACKOFF)) { - header("Backoff: " . Z_CONFIG::$BACKOFF); - } - - set_exception_handler(array($this, 'handleException')); - // TODO: Throw error on some notices but allow DB/Memcached/etc. failures? - //set_error_handler(array($this, 'handleError'), E_ALL | E_USER_ERROR | E_RECOVERABLE_ERROR); - set_error_handler(array($this, 'handleError'), E_USER_ERROR | E_RECOVERABLE_ERROR); - require_once('../model/Error.inc.php'); - - // On testing sites, include notifications in headers - if (Z_CONFIG::$TESTING_SITE) { - Zotero_NotifierObserver::addMessageReceiver(function ($topic, $msg) { - $header = "Zotero-Debug-Notifications"; - if (!empty($this->headers[$header])) { - $notifications = json_decode(base64_decode($this->headers[$header])); - } - else { - $notifications = []; - } - $notifications[] = $msg; - $this->headers[$header] = base64_encode(json_encode($notifications)); - }); - } - - register_shutdown_function(array($this, 'checkDBTransactionState')); - register_shutdown_function(array($this, 'logTotalRequestTime')); - register_shutdown_function(array($this, 'checkForFatalError')); - register_shutdown_function(array($this, 'addHeaders')); - $this->method = $_SERVER['REQUEST_METHOD']; - - if (!in_array($this->method, array('HEAD', 'OPTIONS', 'GET', 'PUT', 'POST', 'DELETE', 'PATCH'))) { - $this->e501(); - } - - StatsD::increment("api.request.method." . strtolower($this->method), 0.25); - - // There doesn't seem to be a way for PHP to start processing the request - // before the entire body is sent, so an Expect: 100 Continue will, - // depending on the client, either fail or cause a delay while the client - // waits for the 100 response. To make this explicit, we return an error. - if (!empty($_SERVER['HTTP_EXPECT'])) { - header("HTTP/1.1 417 Expectation Failed"); - die("Expect header is not supported"); - } - - // CORS - if (isset($_SERVER['HTTP_ORIGIN'])) { - header("Access-Control-Allow-Origin: *"); - header("Access-Control-Allow-Methods: HEAD, GET, POST, PUT, PATCH, DELETE"); - header("Access-Control-Allow-Headers: Authorization, Content-Type, If-Match, If-None-Match, If-Modified-Since-Version, If-Unmodified-Since-Version, Zotero-API-Key, Zotero-API-Version, Zotero-Write-Token"); - header("Access-Control-Expose-Headers: Backoff, ETag, Last-Modified-Version, Link, Retry-After, Total-Results, Zotero-API-Version"); - } - - if (isset($_SERVER['HTTP_CONTINUED'])) { - Zotero_NotifierObserver::setContinued(); - } - - if ($this->method == 'OPTIONS') { - $this->end(); - } - - if (in_array($this->method, array('POST', 'PUT', 'PATCH'))) { - $this->ifUnmodifiedSince = - isset($_SERVER['HTTP_IF_UNMODIFIED_SINCE']) - ? strtotime($_SERVER['HTTP_IF_UNMODIFIED_SINCE']) : false; - - $this->body = file_get_contents("php://input"); - if ($this->body == "" - && !in_array($this->action, array( - 'clear', - 'laststoragesync', - 'removestoragefiles', - 'itemContent'))) { - $this->e400("$this->method data not provided"); - } - - if (!empty($_SERVER['HTTP_CONTENT_ENCODING']) && $_SERVER['HTTP_CONTENT_ENCODING'] == 'gzip') { - // Strip standard gzip header if present - if (substr($this->body, 0, 3) == (chr(31) . chr(139) . chr(8))) { // 1F 8B 08 - $this->body = substr($this->body, 10); - } - - $this->body = gzinflate($this->body); - - // If form data, parse uncompressed data into $_REQUEST. (This might not be used.) - if (isset($_SERVER['CONTENT_TYPE']) - && $_SERVER['CONTENT_TYPE'] == 'application/x-www-form-urlencoded') { - parse_str($this->body, $_POST); - foreach ($_POST as $key => $val) { - $_REQUEST[$key] = $val; - } - } - } - } - - if ($this->profile) { - Zotero_DB::profileStart(); - } - - // If HTTP Basic Auth credentials provided, authenticate - if (isset($_SERVER['PHP_AUTH_USER'])) { - $username = $_SERVER['PHP_AUTH_USER']; - $password = $_SERVER['PHP_AUTH_PW']; - - if ($username == Z_CONFIG::$API_SUPER_USERNAME - && $password == Z_CONFIG::$API_SUPER_PASSWORD) { - $this->userID = 0; - $this->permissions = new Zotero_Permissions; - $this->permissions->setSuper(); - } - - // Allow HTTP Auth for file access - else if (!empty($extra['allowHTTP']) || !empty($extra['auth'])) { - $userID = Zotero_Users::authenticate( - 'password', - array('username' => $username, 'password' => $password) - ); - if (!$userID) { - $this->e401('Invalid login'); - } - $this->httpAuth = true; - $this->userID = $userID; - $this->grantUserPermissions($userID); - } - } - - if (!isset($this->userID)) { - $key = false; - // Allow Zotero-API-Key header - if (!empty($_SERVER['HTTP_ZOTERO_API_KEY'])) { - $key = $_SERVER['HTTP_ZOTERO_API_KEY']; - } - // Allow ?key= - if (isset($_GET['key'])) { - if (!$key) { - $key = $_GET['key']; - } - else if ($_GET['key'] !== $key) { - $this->e400("Zotero-API-Key header and 'key' parameter differ"); - } - } - // If neither of the above passed, allow "Authorization: Bearer " - // - // Apache/mod_php doesn't seem to make Authorization available for auth schemes - // other than Basic/Digest, so use an Apache-specific method to get the header - if (!$key && function_exists('apache_request_headers')) { - $headers = apache_request_headers(); - if (isset($headers['Authorization']) || isset($headers['authorization'])) { - $val = isset($headers['Authorization']) - ? $headers['Authorization'] - : $headers['authorization']; - // Look for "Authorization: Bearer" from OAuth 2.0, and ignore everything else - if (preg_match('/^bearer/i', $val, $matches)) { - if (preg_match('/^bearer +([a-z0-9]+)$/i', $val, $matches)) { - $key = $matches[1]; - } - else { - $this->e400("Invalid Authorization header format"); - } - } - } - } - if ($key) { - $keyObj = Zotero_Keys::authenticate($key); - if (!$keyObj) { - $this->e403('Invalid key'); - } - $this->apiKey = $key; - $this->userID = $keyObj->userID; - $this->permissions = $keyObj->getPermissions(); - - // Check Zotero-Write-Token if it exists to make sure - // this isn't a duplicate request - if ($this->isWriteMethod()) { - if ($cacheKey = $this->getWriteTokenCacheKey()) { - if (Z_Core::$MC->get($cacheKey)) { - $this->e412("Write token already used"); - } - } - } - } - // Website cookie authentication - // - // For CSRF protection, session cookie has to be passed in the 'session' parameter, - // which JS code on other sites can't do because it can't access the website cookie. - else if (!empty($_GET['session']) && - ($this->userID = Zotero_Users::getUserIDFromSessionID($_GET['session']))) { - // Users who haven't synced may not exist in our DB - if (!Zotero_Users::exists($this->userID)) { - Zotero_Users::add($this->userID); - } - $this->grantUserPermissions($this->userID); - $this->cookieAuth = true; - } - // No credentials provided - else { - if (!empty($_GET['auth']) || !empty($extra['auth'])) { - $this->e401(); - } - - // Explicit auth request or not a GET request - // - // /users//keys is an exception, since the key is embedded in the URL - if ($this->method != "GET" && $this->action != 'keys') { - $this->e403('An API key is required for write requests.'); - } - - // Anonymous request - $this->permissions = new Zotero_Permissions; - $this->permissions->setAnonymous(); - } - } - - // Request limiter needs initialized authentication parameters - $this->initRequestLimiter(); - - $this->uri = Z_CONFIG::$API_BASE_URI . substr($_SERVER["REQUEST_URI"], 1); - - // Get object user - if (isset($this->objectUserID)) { - if (!$this->objectUserID) { - $this->e400("Invalid user ID", Z_ERROR_INVALID_INPUT); - } - - try { - $this->objectLibraryID = Zotero_Users::getLibraryIDFromUserID($this->objectUserID); - } - catch (Exception $e) { - if ($e->getCode() == Z_ERROR_USER_NOT_FOUND) { - // Switch to DB writer - Zotero_DB::readOnly(false); - try { - Zotero_Users::addFromWWW($this->objectUserID); - } - catch (Exception $e) { - if ($e->getCode() == Z_ERROR_USER_NOT_FOUND) { - $this->e404("User $this->objectUserID not found"); - } - throw ($e); - } - $this->objectLibraryID = Zotero_Users::getLibraryIDFromUserID($this->objectUserID); - } - else { - throw ($e); - } - } - - // Make sure user isn't banned - if (!Zotero_Users::isValidUser($this->objectUserID)) { - $this->e404(); - } - } - // Get object group - else if (isset($this->objectGroupID)) { - if (!$this->objectGroupID) { - $this->e400("Invalid group ID", Z_ERROR_INVALID_INPUT); - } - // Make sure group exists - $group = Zotero_Groups::get($this->objectGroupID); - if (!$group) { - $this->e404(); - } - // Don't show groups owned by banned users - if (!Zotero_Users::isValidUser($group->ownerUserID)) { - $this->e404(); - } - $this->objectLibraryID = Zotero_Groups::getLibraryIDFromGroupID($this->objectGroupID); - } - - $apiVersion = !empty($_SERVER['HTTP_ZOTERO_API_VERSION']) - ? (int) $_SERVER['HTTP_ZOTERO_API_VERSION'] - : false; - // Serve v1 to ZotPad 1.x, at Mikko's request - if (!$apiVersion && !empty($_SERVER['HTTP_USER_AGENT']) - && strpos($_SERVER['HTTP_USER_AGENT'], 'ZotPad 1') === 0) { - $apiVersion = 1; - } - - if (!empty($extra['publications'])) { - // Query parameters not yet parsed, so check version parameter - if (($apiVersion && $apiVersion < 3) - || (!empty($_REQUEST['v']) && $_REQUEST['v'] < 3) - || (!empty($_REQUEST['version']) && $_REQUEST['version'] == 1)) { - $this->e404(); - } - - if ($this->isWriteMethod()) { - $this->e405("Please upgrade to the latest Zotero 5.0 beta to update My Publications.", Z_ERROR_INVALID_INPUT); - } - - $this->permissions->setPublications(); - // Added to queryParams below - $this->publications = true; - - // If no publications items in main user library, see if there's a legacy publications library - if (!Zotero_Users::hasPublicationsInUserLibrary($this->objectUserID)) { - $publicationsLibraryID = Zotero_Users::getLibraryIDFromUserID( - $this->objectUserID, 'publications' - ); - if ($publicationsLibraryID) { - $this->legacyPublications = true; - $this->objectLibraryID = $publicationsLibraryID; - } - } - - // TEMP: Remove after integrated publications upgrade - if ($this->action == 'settings') { - // If publications in either legacy library or user library, show upgrade error - if (Zotero_Users::hasPublicationsInUserLibrary($this->objectUserID) - || Zotero_Users::hasPublicationsInLegacyLibrary($this->objectUserID)) { - $this->e400("Please upgrade to the latest Zotero 5.0 beta to continue syncing My Publications.", Z_ERROR_INVALID_INPUT); - } - $this->apiVersion = 3; - header("Total-Results: 0"); - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - $this->queryParams['format'] = 'json'; - echo json_encode([]); - $this->end(); - } - else if ($this->action == 'deleted') { - $this->apiVersion = 3; - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - $this->queryParams['format'] = 'json'; - echo json_encode(new stdClass); - $this->end(); - } - } - - // Return 409 if target library is locked - switch ($this->method) { - case 'POST': - case 'PUT': - case 'DELETE': - switch ($this->action) { - // Library lock doesn't matter for some admin requests - case 'keys': - case 'storageadmin': - break; - - default: - if ($this->objectLibraryID && Zotero_Libraries::isLocked($this->objectLibraryID)) { - $this->e409("Target library is locked"); - } - break; - } - } - - $this->scopeObject = !empty($extra['scopeObject']) ? $extra['scopeObject'] : $this->scopeObject; - $this->subset = !empty($extra['subset']) ? $extra['subset'] : $this->subset; - - $this->fileMode = !empty($extra['file']) - ? (!empty($_GET['info']) ? 'info' : 'download') - : false; - $this->fileView = !empty($extra['view']); - - $this->singleObject = $this->objectKey && !$this->subset; - - $this->checkLibraryIfModifiedSinceVersion($this->action); - - // If Accept header includes application/atom+xml, send Atom, as long as there's no 'format' - $atomAccepted = false; - if (!empty($_SERVER['HTTP_ACCEPT'])) { - $accept = preg_split('/\s*,\s*/', $_SERVER['HTTP_ACCEPT']); - $atomAccepted = in_array('application/atom+xml', $accept); - } - - $this->queryParams = Zotero_API::parseQueryParams( - $_SERVER['QUERY_STRING'], - $this->action, - $this->singleObject, - $apiVersion, - $atomAccepted - ); - if ($this->publications) { - $this->queryParams['publications'] = true; - // Don't show trashed items in publications view - $this->queryParams['includeTrashed'] = false; - } - - // Sorting by Item Type or Added By currently require writing to shard tables, so don't - // send those to the read replicas - if ($this->queryParams['sort'] == 'itemType' || $this->queryParams['sort'] == 'addedBy') { - Zotero_DB::readOnly(false); - } - - $this->apiVersion = $version = $this->queryParams['v']; - - header("Zotero-API-Version: " . $version); - StatsD::increment("api.request.version.v" . $version, 0.25); - } - - - public function index() { - $this->e400("Invalid Request"); - } - - - public function noop() { - echo "Nothing to see here."; - exit; - } - - - /** - * Used for integration tests - * - * Valid only on testing site - */ - public function clear() { - if (!$this->permissions->isSuper()) { - $this->e404(); - } - - if (!Z_ENV_TESTING_SITE) { - $this->e404(); - } - - $this->allowMethods(array('POST')); - - Zotero_Libraries::clearAllData($this->objectLibraryID); - - $this->e204(); - } - - - /** - * Used for integration tests - * - * Valid only on testing site - */ - public function testSetup() { - if (!$this->permissions->isSuper()) { - $this->e404(); - } - - if (!Z_ENV_TESTING_SITE) { - $this->e404(); - } - - $this->allowMethods(['POST']); - - if (empty($_GET['u'])) { - throw new Exception("User not provided (e.g., ?u=1)"); - } - $userID = $_GET['u']; - - // Clear keys - $keys = Zotero_Keys::getUserKeys($userID); - foreach ($keys as $keyObj) { - $keyObj->erase(); - } - $keys = Zotero_Keys::getUserKeys($userID); - if ($keys) { - throw new Exception("Keys still exist"); - } - // Create new key - $keyObj = new Zotero_Key; - $keyObj->userID = $userID; - $keyObj->name = "Tests Key"; - $libraryID = Zotero_Users::getLibraryIDFromUserID($userID); - $keyObj->setPermission($libraryID, 'library', true); - $keyObj->setPermission($libraryID, 'notes', true); - $keyObj->setPermission($libraryID, 'write', true); - $keyObj->setPermission(0, 'group', true); - $keyObj->setPermission(0, 'write', true); - $keyObj->save(); - $key = $keyObj->key; - - Zotero_DB::beginTransaction(); - - // Clear data - Zotero_Users::clearAllData($userID); - - // Delete publications library, so we can test auto-creating it - $publicationsLibraryID = Zotero_Users::getLibraryIDFromUserID($userID, 'publications'); - if ($publicationsLibraryID) { - // Delete user publications shard library - $sql = "DELETE FROM shardLibraries WHERE libraryID=?"; - Zotero_DB::query($sql, $publicationsLibraryID, Zotero_Shards::getByUserID($userID)); - - // Delete user publications library - $sql = "DELETE FROM libraries WHERE libraryID=?"; - Zotero_DB::query($sql, $publicationsLibraryID); - - Z_Core::$MC->delete('userPublicationsLibraryID_' . $userID); - Z_Core::$MC->delete('libraryUserID_' . $publicationsLibraryID); - } - Zotero_DB::commit(); - - echo json_encode([ - "apiKey" => $key - ]); - $this->end(); - } - - - // - // Protected methods - // - - protected function initRequestLimiter() { - $limits = $this->limits(); - - // Skip request limiter if controller 'limits' functions doesn't return anything - if (empty($limits)) { - return; - } - - // Skip if neither rate nor concurrency limit isn't set - if (empty($limits['rate']) && empty($limits['concurrency'])) { - return; - } - - // Skip if logOnly parameter isn't set - // (other parameters are checked in Z_RequestLimiter) - if (!empty($limits['rate']) && !isset($limits['rate']['logOnly']) || - !empty($limits['concurrency']) && !isset($limits['concurrency']['logOnly'])) { - Z_Core::logError('Warning: Missing logOnly parameter, skipping request limiter'); - return; - } - - // Skip if failed to initialize (i.e. Redis error) - if (!Z_RequestLimiter::init()) return; - - // Initialize rate limiter - if (!empty($limits['rate'])) { - if (Z_RequestLimiter::checkBucketRate($limits['rate']) === false) { - StatsD::increment('api.request.limit.rate.rejected', 1); - Z_Core::logError(($limits['rate']['logOnly'] ? '(WARN) ' : '') - . 'Request rate limit exceeded for ' . $limits['rate']['bucket'] - . ' for ' . $this->method . ' to ' . $_SERVER['REQUEST_URI']); - if (!$limits['rate']['logOnly']) { - // Suggest to retry when the full capacity will be reached - header('Retry-After: ' . (int) $limits['rate']['capacity'] / $limits['rate']['replenishRate']); - $this->e429('Request rate limit exceeded'); - } - } - } - - // Initialize concurrency limiter - if (!empty($limits['concurrency'])) { - if (Z_RequestLimiter::beginConcurrentRequest($limits['concurrency']) === false) { - StatsD::increment('api.request.limit.concurrency.rejected', 1); - Z_Core::logError(($limits['concurrency']['logOnly'] ? '(WARN) ' : '') - . 'Concurrent request limit exceeded for ' . $limits['concurrency']['bucket'] - . ' for ' . $this->method . ' to ' . $_SERVER['REQUEST_URI']); - if (!$limits['concurrency']['logOnly']) { - // Randomize retry suggestion delay to spread future requests in a wider time interval - header('Retry-After: ' . rand(1, 30)); - $this->e429('Concurrent request limit exceeded'); - } - } - } - } - - /** - * Override this function on other controllers - * to set different request limits - * @return array ['rate'=>[], 'concurrency'=>[]] - */ - protected function limits() { - $limits = []; - // Rate limit - // For authorized request - if (!empty($this->userID)) { - // 10 requests per second, 100 requests burst - $limits['rate'] = [ - 'logOnly' => false, - 'bucket' => $this->userID . '_' . $_SERVER['REMOTE_ADDR'], - 'capacity' => 100, - 'replenishRate' => 10 - ]; - } - // For anonymous request - else { - // 30 requests per second, no burst - $limits['rate'] = [ - 'logOnly' => false, - 'bucket' => $_SERVER['REMOTE_ADDR'], - 'capacity' => 30, - 'replenishRate' => 30 - ]; - } - - // Concurrency limit - // For authorized request - if (!empty($this->userID)) { - // 5 concurrent requests - $limits['concurrency'] = [ - 'logOnly' => false, - 'bucket' => $this->userID, - 'capacity' => 5, - // Maximum possible time the request can take - 'ttl' => 60 - ]; - } - // For anonymous request - else { - // 20 concurrent requests - $limits['concurrency'] = [ - 'logOnly' => false, - 'bucket' => $_SERVER['REMOTE_ADDR'], - 'capacity' => 20, - // Maximum possible time the request can take - 'ttl' => 60 - ]; - } - return $limits; - } - - protected function getFeedNamePrefix($libraryID=false) { - $prefix = "Zotero / "; - if ($libraryID) { - $type = Zotero_Libraries::getType($this->objectLibraryID); - } - else { - $type = false; - } - switch ($type) { - case "user": - $title = $prefix . Zotero_Libraries::getName($this->objectLibraryID); - break; - - case "group": - $title = $prefix . "" . Zotero_Libraries::getName($this->objectLibraryID) . " Group"; - break; - - default: - return $prefix; - } - return $title . " / "; - } - - - /** - * Verify the HTTP method - */ - protected function allowMethods($methods, $message="Method not allowed") { - if (!in_array($this->method, $methods)) { - header("Allow: " . implode(", ", $methods)); - $this->e405($message); - } - } - - - protected function isWriteMethod() { - return in_array($this->method, array('POST', 'PUT', 'PATCH', 'DELETE')); - } - - - protected function handleObjectWrite($objectType, $obj=null) { - if (!is_object($obj) && !is_null($obj)) { - throw new Exception('$obj must be a data object or null'); - } - - $objectTypePlural = \Zotero\DataObjectUtilities::getObjectTypePlural($objectType); - $objectsClassName = "Zotero_" . ucwords($objectTypePlural); - - $json = !empty($this->body) ? $this->jsonDecode($this->body) : false; - $objectVersionValidated = $this->checkSingleObjectWriteVersion($objectType, $obj, $json); - - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - - // Update item - if ($this->method == 'PUT' || $this->method == 'PATCH') { - if ($this->apiVersion < 2) { - $this->allowMethods(['PUT']); - } - - if (!$obj) { - $className = "Zotero_" . ucwords($objectType); - $obj = new $className; - $obj->libraryID = $this->objectLibraryID; - $obj->key = $this->objectKey; - } - if ($objectType == 'item') { - $changed = Zotero_Items::updateFromJSON( - $obj, - $json, - null, - $this->queryParams, - $this->userID, - $objectVersionValidated ? 0 : 2, - $this->method == 'PATCH' - ); - } - else { - $changed = $objectsClassName::updateFromJSON( - $obj, - $json, - $this->queryParams, - $this->userID, - $objectVersionValidated ? 0 : 2, - $this->method == 'PATCH' - ); - } - - // If not updated, return the original library version - if (!$changed) { - $this->libraryVersion = Zotero_Libraries::getOriginalVersion( - $this->objectLibraryID - ); - } - - if ($cacheKey = $this->getWriteTokenCacheKey()) { - Z_Core::$MC->set($cacheKey, true, $this->writeTokenCacheTime); - } - } - // Delete item - else if ($this->method == 'DELETE') { - $objectsClassName::delete($this->objectLibraryID, $this->objectKey); - } - else { - throw new Exception("Unexpected method $this->method"); - } - - if ($this->apiVersion >= 2 || $this->method == 'DELETE') { - $this->e204(); - } - - return $obj; - } - - - - /** - * For single-object requests for some actions, require If-Unmodified-Since-Version, the - * deprecated If-Match, or a JSON version property, and make sure the object hasn't been - * modified - * - * @param {String} $objectType - * @param {Zotero_DataObject} - * @return {Boolean} - True if the object has been cleared for writing, or false if the JSON - * version property still needs to pass - */ - protected function checkSingleObjectWriteVersion($objectType, $obj=null, $json=false) { - if (!is_object($obj) && !is_null($obj)) { - throw new Exception('$obj must be a data object or null'); - } - - // In versions below 3, no writes to missing objects - if (!$obj && $this->apiVersion < 3) { - $this->e404(ucwords($objectType) . " not found"); - } - - if (!in_array($objectType, array('item', 'collection', 'search', 'setting'))) { - throw new Exception("Invalid object type"); - } - - if (Z_CONFIG::$TESTING_SITE && !empty($_GET['skipetag'])) { - return true; - } - - // If-Match (deprecated) - if ($this->apiVersion < 2) { - if (empty($_SERVER['HTTP_IF_MATCH'])) { - if ($this->method == 'DELETE') { - $this->e428("If-Match must be provided for delete requests"); - } - else { - return false; - } - } - - if (!preg_match('/^"?([a-f0-9]{32})"?$/', $_SERVER['HTTP_IF_MATCH'], $matches)) { - $this->e400("Invalid ETag in If-Match header"); - } - - if ($obj->etag != $matches[1]) { - $this->e412("ETag does not match current version of $objectType"); - } - - return true; - } - - // Get version from If-Unmodified-Since-Version header - $headerVersion = isset($_SERVER['HTTP_IF_UNMODIFIED_SINCE_VERSION']) - ? $_SERVER['HTTP_IF_UNMODIFIED_SINCE_VERSION'] : false; - - // Get version from JSON 'version' property - if ($json) { - $json = Zotero_API::extractEditableJSON($json); - - if ($this->apiVersion >= 3) { - $versionProp = 'version'; - } - else { - $versionProp = $objectType == 'setting' ? 'version' : $objectType . "Version"; - } - $propVersion = isset($json->$versionProp) ? $json->$versionProp : false; - } - else { - $propVersion = false; - } - - if ($this->method == 'DELETE' && $headerVersion === false) { - $this->e428("If-Unmodified-Since-Version must be provided for delete requests"); - } - - if ($headerVersion !== false) { - if (!is_numeric($headerVersion)) { - $this->e400("Invalid If-Unmodified-Since-Version value '$headerVersion'"); - } - $headerVersion = (int) $headerVersion; - } - if ($propVersion !== false) { - if (!is_numeric($propVersion)) { - $this->e400("Invalid JSON 'version' property value '$propVersion'"); - } - $propVersion = (int) $propVersion; - } - - // If both header and property given, they have to match - if ($headerVersion !== false && $propVersion !== false && $headerVersion !== $propVersion) { - $this->e400("If-Unmodified-Since-Version value does not match JSON '$versionProp' property " - . "($headerVersion != $propVersion)"); - } - - $version = $headerVersion !== false ? $headerVersion : $propVersion; - - // If object doesn't exist, version has to be 0 if provided - if (!$obj) { - // PATCH is only allowed for missing objects with version 0 - if ($this->method == "PATCH" && $version === false) { - $this->e404(ucwords($objectType) . " not found " - . "(to create, use If-Unmodified-Since-Version: 0, JSON 'version' 0, or PUT method)"); - } - if ($version > 0) { - $this->e404(ucwords($objectType) . " not found (expected version $version)"); - } - return true; - } - - if ($version === false) { - throw new HTTPException("Either If-Unmodified-Since-Version or object version " - . "property must be provided for key-based writes", 428 - ); - } - - if ($obj->version !== $version) { - $this->libraryVersion = $obj->version; - $this->e412(ucwords($objectType) . " has been modified since specified version " - . "(expected $version, found " . $obj->version . ")"); - } - return true; - } - - - /** - * For multi-object requests for some actions, require - * If-Unmodified-Since-Version and make sure the library - * hasn't been modified - * - * @param boolean $required Return 428 if header is missing - * @return boolean True if library version was checked, false if not - */ - protected function checkLibraryIfUnmodifiedSinceVersion($required=false) { - if (Z_CONFIG::$TESTING_SITE && !empty($_GET['skipetag'])) { - return true; - } - - if (!isset($_SERVER['HTTP_IF_UNMODIFIED_SINCE_VERSION'])) { - if ($required) { - $this->e428("If-Unmodified-Since-Version not provided"); - } - return false; - } - - $version = $_SERVER['HTTP_IF_UNMODIFIED_SINCE_VERSION']; - - if (!is_numeric($version)) { - $this->e400("Invalid If-Unmodified-Since-Version value"); - } - - $libraryVersion = Zotero_Libraries::getVersion($this->objectLibraryID); - if ($libraryVersion > $version) { - $this->e412("Library has been modified since specified version " - . "(expected $version, found $libraryVersion)"); - } - return true; - } - - - /** - * For multi-object requests for some actions, return 304 Not Modified - * if the library hasn't been updated since If-Modified-Since-Version - */ - protected function checkLibraryIfModifiedSinceVersion($action) { - if (!$this->singleObject - && in_array( - $action, ["items", "collections", "searches", "settings", "tags"] - ) - && isset($_SERVER['HTTP_IF_MODIFIED_SINCE_VERSION']) - && !$this->isWriteMethod() - && $this->permissions->canAccess($this->objectLibraryID) - && Zotero_Libraries::getVersion($this->objectLibraryID) - <= $_SERVER['HTTP_IF_MODIFIED_SINCE_VERSION']) { - $this->e304(); - } - } - - - protected function requireContentType($contentType) { - if (empty($_SERVER['CONTENT_TYPE']) - || ($_SERVER['CONTENT_TYPE'] != $contentType - // Workaround for Firefox bug before Fx44 - && $_SERVER['CONTENT_TYPE'] != $contentType . "; charset=UTF-8")) { - throw new Exception("Content-Type must be $contentType", Z_ERROR_INVALID_INPUT); - } - } - - /** - * For HTTP Auth and session-based auth, generate blanket user permissions - * manually, since there's no key object - */ - protected function grantUserPermissions($userID) { - $this->permissions = new Zotero_Permissions($userID); - $libraryID = Zotero_Users::getLibraryIDFromUserID($userID); - - // Grant user permissions on own library and all groups - $this->permissions->setPermission($libraryID, 'library', true); - $this->permissions->setPermission($libraryID, 'files', true); - $this->permissions->setPermission($libraryID, 'notes', true); - $this->permissions->setPermission($libraryID, 'write', true); - $this->permissions->setPermission(0, 'library', true); - $this->permissions->setPermission(0, 'write', true); - } - - - protected function getWriteTokenCacheKey() { - if (empty($_SERVER['HTTP_ZOTERO_WRITE_TOKEN'])) { - return false; - } - if (strlen($_SERVER['HTTP_ZOTERO_WRITE_TOKEN']) < 5 || strlen($_SERVER['HTTP_ZOTERO_WRITE_TOKEN']) > 32) { - $this->e400("Write token must be 5-32 characters in length"); - } - if (!$this->apiKey) { - $this->e400("Write token cannot be used without an API key"); - } - return "writeToken_" . md5($this->apiKey . "_" . $_SERVER['HTTP_ZOTERO_WRITE_TOKEN']); - } - - - /** - * Handler for HTTP shortcut functions (e404(), e500()) - */ - public function __call($name, $arguments) { - if (!preg_match("/^e([1-5])([0-9]{2})$/", $name, $matches)) { - throw new Exception("Invalid function $name"); - } - - $this->responseCode = (int) ($matches[1] . $matches[2]); - - // On 4xx or 5xx errors, rollback all open transactions - // and don't send Last-Modified-Version (except for 412 errors) - if ($matches[1] == "4" || $matches[1] == "5") { - if (!$this->libraryVersionOnFailure && $this->responseCode != 412) { - $this->libraryVersion = null; - $this->etag = null; - } - Zotero_DB::rollback(true); - } - - if (isset($arguments[0])) { - echo htmlspecialchars($arguments[0]); - } - else { - // Default messages for some codes - switch ($this->responseCode) { - case 401: - echo "Access denied"; - break; - - case 403: - echo "Forbidden"; - break; - - case 404: - echo "Not found"; - break; - - case 405: - echo "Method not allowed"; - break; - - case 429: - echo "Too many requests"; - break; - - case 500: - echo "An error occurred"; - break; - - case 501: - echo "Method is not implemented"; - break; - - case 503: - echo "Service unavailable"; - break; - } - } - - $this->end(); - } - - - protected function redirect($url, $httpCode=302) { - if (!in_array($httpCode, array(301, 302, 303))) { - throw new Exception("Invalid redirect code"); - } - - $this->libraryVersion = null; - $this->etag = null; - $this->responseXML = null; - - $this->responseCode = $httpCode; - header("Location: " . $url, false, $httpCode); - $this->end(); - } - - - protected function end() { - if (Z_RequestLimiter::isConcurrentRequestActive()) { - Z_RequestLimiter::finishConcurrentRequest(); - } - - if ($this->profile) { - Zotero_DB::profileEnd($this->objectLibraryID, true); - } - - switch ($this->responseCode) { - case 200: - // Output a Content-Type header for the given format - // - // Note that this overrides any Content-Type set elsewhere. To force a content - // type elsewhere, clear $this->queryParams['format'] when calling header() - // manually. - // - // TODO: Check headers_list so that clearing the format parameter manually isn't - // necessary? Performance? - if (isset($this->queryParams['format'])) { - Zotero_API::outputContentType($this->queryParams['format']); - } - break; - - case 301: - case 302: - case 303: - // Handled in $this->redirect() - break; - - case 401: - header('WWW-Authenticate: Basic realm="Zotero API"'); - header('HTTP/1.1 401 Unauthorized'); - break; - - // PHP completes these automatically - case 201: - case 204: - case 300: - case 304: - case 400: - case 403: - case 404: - case 405: - case 409: - case 412: - case 413: - case 422: - case 500: - case 501: - case 503: - header("HTTP/1.1 " . $this->responseCode); - break; - - case 428: - header("HTTP/1.1 428 Precondition Required"); - break; - - case 429: - header("HTTP/1.1 429 Too Many Requests"); - break; - - default: - throw new Exception("Unsupported response code " . $this->responseCode); - } - - if (isset($this->libraryVersion)) { - if ($this->apiVersion >= 2) { - header("Last-Modified-Version: " . $this->libraryVersion); - } - - // Send notification if library has changed - if ($this->isWriteMethod()) { - if ($this->libraryVersion > - Zotero_Libraries::getOriginalVersion($this->objectLibraryID)) { - Zotero_Notifier::trigger( - 'modify', - 'library', - $this->objectLibraryID, - [ - $this->objectLibraryID => [ - 'version' => $this->libraryVersion - ] - ] - ); - } - } - } - - if (isset($this->etag)) { - header("ETag: " . $this->etag); - } - - if ($this->responseXML instanceof SimpleXMLElement) { - if (!$this->responseCode) { - $updated = (string) $this->responseXML->updated; - if ($updated) { - $updated = strtotime($updated); - - $ifModifiedSince = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? $_SERVER['HTTP_IF_MODIFIED_SINCE'] : false; - $ifModifiedSince = strtotime($ifModifiedSince); - if ($ifModifiedSince >= $updated) { - header('HTTP/1.1 304 Not Modified'); - exit; - } - - $lastModified = substr(date('r', $updated), 0, -5) . "GMT"; - header("Last-Modified: $lastModified"); - } - } - - $xmlstr = $this->responseXML->asXML(); - - // TEMP: Strip control characters - $xmlstr = Zotero_Utilities::cleanString($xmlstr, true); - - $doc = new DOMDocument('1.0'); - $doc->loadXML($xmlstr); - $doc->formatOutput = true; - - echo $doc->saveXML(); - } - - $this->logRequestTime(); - self::addHeaders(); - echo ob_get_clean(); - exit; - } - - - protected function currentRequestTime() { - return round(microtime(true) - $this->startTime, 2); - } - - - protected function logRequestTime($point=false) { - if ($this->timeLogged) { - return; - } - $time = $this->currentRequestTime(); - if ($time > $this->timeLogThreshold) { - $this->timeLogged = true; - - $shardHostStr = ""; - if (!empty($this->objectLibraryID)) { - $shardID = Zotero_Shards::getByLibraryID($this->objectLibraryID); - $shardInfo = Zotero_Shards::getShardInfo($shardID); - $shardHostStr = " with shard host " . $shardInfo['shardHostID']; - } - - error_log( - "Slow API request" . ($point ? " at point " . $point : "") - . $shardHostStr . ": " - . $time . " sec for " - . $_SERVER['REQUEST_METHOD'] . " " . $_SERVER['REQUEST_URI'] - ); - } - } - - - protected function jsonDecode($json) { - $obj = json_decode($json); - $this->checkJSONError(); - Zotero_Utilities::cleanStringRecursive($obj); - return $obj; - } - - - protected function checkJSONError() { - switch (json_last_error()) { - case JSON_ERROR_DEPTH: - $error = 'Maximum stack depth exceeded'; - break; - - case JSON_ERROR_CTRL_CHAR: - $error = 'Unexpected control character found'; - break; - case JSON_ERROR_SYNTAX: - $error = 'Syntax error, malformed JSON'; - break; - - case JSON_ERROR_NONE: - default: - $error = ''; - } - - if (!empty($error)) { - throw new Exception("JSON Error: $error", Z_ERROR_INVALID_INPUT); - } - } - - - public function handleException(Throwable $e) { - $error = Zotero_Errors::parseException($e); - - if (!empty($error['log'])) { - Z_Core::reportErrors([$error['exception']], $error['code'] != 413 ? $this->body : ''); - } - - if ($error['code'] != '500') { - $errFunc = "e" . $error['code']; - $this->$errFunc($error['message']); - } - - // On testing site, display unexpected error messages - if (Z_ENV_TESTING_SITE) { - $this->e500($error['message']); - } - - $this->e500(); - } - - public function handleError($no, $str, $file, $line) { - $e = new ErrorException($str, $no, 0, $file, $line); - $this->handleException($e); - } - - - public function checkDBTransactionState() { - if (Zotero_DB::transactionInProgress()) { - error_log("Transaction still in progress at request end! " - . "[" . $this->method . " " . $_SERVER['REQUEST_URI'] . "]"); - } - } - - public function addHeaders() { - foreach ($this->headers as $header => $value) { - header("$header: $value"); - } - $this->headers = []; - } - - public function checkForFatalError() { - $lastError = error_get_last(); - if (!empty($lastError) && $lastError['type'] == E_ERROR) { - header('Status: 500 Internal Server Error'); - header('HTTP/1.0 500 Internal Server Error'); - } - } - - - public function logTotalRequestTime() { - if (!Z_CONFIG::$STATSD_ENABLED) { - return; - } - - try { - if (!empty($this->objectLibraryID)) { - $shardID = Zotero_Shards::getByLibraryID($this->objectLibraryID); - $shardInfo = Zotero_Shards::getShardInfo($shardID); - $shardHostID = (int) $shardInfo['shardHostID']; - StatsD::timing( - "api.request.total_by_shard.$shardHostID", - (microtime(true) - $this->startTime) * 1000, - 0.25 - ); - } - } - catch (Exception $e) { - error_log("WARNING: " . $e); - } - - StatsD::timing("api.memcached", Z_Core::$MC->requestTime * 1000, 0.25); - StatsD::timing("api.request.total", (microtime(true) - $this->startTime) * 1000, 0.25); - } -} -?> diff --git a/controllers/CollectionsController.php b/controllers/CollectionsController.php deleted file mode 100644 index 1f64c0da..00000000 --- a/controllers/CollectionsController.php +++ /dev/null @@ -1,224 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class CollectionsController extends ApiController { - public function collections() { - // Check for general library access - if (!$this->permissions->canAccess($this->objectLibraryID)) { - $this->e403(); - } - - if ($this->isWriteMethod()) { - // Check for library write access - if (!$this->permissions->canWrite($this->objectLibraryID)) { - $this->e403("Write access denied"); - } - - // Make sure library hasn't been modified - if (!$this->singleObject) { - $libraryTimestampChecked = $this->checkLibraryIfUnmodifiedSinceVersion(); - } - - Zotero_Libraries::updateVersionAndTimestamp($this->objectLibraryID); - } - - $collectionIDs = array(); - $collectionKeys = array(); - $results = array(); - - // Single collection - if ($this->singleObject) { - $this->allowMethods(['HEAD', 'GET', 'PUT', 'PATCH', 'DELETE']); - - if (!Zotero_ID::isValidKey($this->objectKey)) { - $this->e404(); - } - - $collection = Zotero_Collections::getByLibraryAndKey($this->objectLibraryID, $this->objectKey); - - if ($this->isWriteMethod()) { - $collection = $this->handleObjectWrite( - 'collection', $collection ? $collection : null - ); - $this->queryParams['content'] = ['json']; - } - - if (!$collection) { - $this->e404("Collection not found"); - } - - $this->libraryVersion = $collection->version; - - if ($this->method == 'HEAD') { - $this->end(); - } - - switch ($this->queryParams['format']) { - case 'atom': - $this->responseXML = Zotero_Collections::convertCollectionToAtom( - $collection, $this->queryParams - ); - break; - - case 'json': - $json = $collection->toResponseJSON($this->queryParams, $this->permissions); - echo Zotero_Utilities::formatJSON($json); - break; - - default: - throw new Exception("Unexpected format '" . $this->queryParams['format'] . "'"); - } - } - // Multiple collections - else { - $this->allowMethods(['HEAD', 'GET', 'POST', 'DELETE']); - - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - - if ($this->scopeObject) { - $this->allowMethods(array('GET')); - - switch ($this->scopeObject) { - case 'collections': - $collection = Zotero_Collections::getByLibraryAndKey($this->objectLibraryID, $this->scopeObjectKey); - if (!$collection) { - $this->e404("Collection not found"); - } - $title = "Child Collections of ‘$collection->name'’"; - $collectionIDs = $collection->getChildCollections(); - break; - - default: - throw new Exception("Invalid collections scope object '$this->scopeObject'"); - } - } - else { - // Top-level items - if ($this->subset == 'top') { - $this->allowMethods(array('GET')); - - $title = "Top-Level Collections"; - $results = Zotero_Collections::search($this->objectLibraryID, true, $this->queryParams); - } - else { - // Create a collection - if ($this->method == 'POST') { - $this->queryParams['format'] = 'writereport'; - - $obj = $this->jsonDecode($this->body); - $results = Zotero_Collections::updateMultipleFromJSON( - $obj, - $this->queryParams, - $this->objectLibraryID, - $this->userID, - $this->permissions, - $libraryTimestampChecked ? 0 : 1, - null - ); - - if ($cacheKey = $this->getWriteTokenCacheKey()) { - Z_Core::$MC->set($cacheKey, true, $this->writeTokenCacheTime); - } - - if ($this->apiVersion < 2) { - $uri = Zotero_API::getCollectionsURI($this->objectLibraryID); - $keys = array_merge( - get_object_vars($results['success']), - get_object_vars($results['unchanged']) - ); - $queryString = "collectionKey=" - . urlencode(implode(",", $keys)) - . "&format=atom&content=json&order=collectionKeyList&sort=asc"; - if ($this->apiKey) { - $queryString .= "&key=" . $this->apiKey; - } - $uri .= "?" . $queryString; - - $this->queryParams = Zotero_API::parseQueryParams( - $queryString, - $this->action, - true, - $this->apiVersion - ); - - $title = "Collections"; - $results = Zotero_Collections::search($this->objectLibraryID, false, $this->queryParams); - } - } - // Delete collections - else if ($this->method == 'DELETE') { - Zotero_DB::beginTransaction(); - foreach ($this->queryParams['collectionKey'] as $collectionKey) { - Zotero_Collections::delete($this->objectLibraryID, $collectionKey); - } - Zotero_DB::commit(); - $this->e204(); - } - // Display collections - else { - $title = "Collections"; - $results = Zotero_Collections::search($this->objectLibraryID, false, $this->queryParams); - } - } - } - - if ($collectionIDs) { - $this->queryParams['collectionIDs'] = $collectionIDs; - $results = Zotero_Collections::search($this->objectLibraryID, false, $this->queryParams); - } - - $options = [ - 'action' => $this->action, - 'uri' => $this->uri, - 'results' => $results, - 'requestParams' => $this->queryParams, - 'permissions' => $this->permissions, - 'head' => $this->method == 'HEAD' - ]; - switch ($this->queryParams['format']) { - case 'atom': - $this->responseXML = Zotero_API::multiResponse(array_merge($options, [ - 'title' => $this->getFeedNamePrefix($this->objectLibraryID) . $title - ])); - break; - - case 'json': - case 'keys': - case 'versions': - case 'writereport': - Zotero_API::multiResponse($options); - break; - - default: - throw new Exception("Unexpected format '" . $this->queryParams['format'] . "'"); - } - } - - $this->end(); - } -} diff --git a/controllers/DeletedController.php b/controllers/DeletedController.php deleted file mode 100644 index 2682769f..00000000 --- a/controllers/DeletedController.php +++ /dev/null @@ -1,92 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class DeletedController extends ApiController { - public function deleted() { - if ($this->apiVersion < 2) { - $this->e404(); - } - - $this->allowMethods(array('GET')); - - if (!$this->permissions->canAccess($this->objectLibraryID)) { - $this->e403(); - } - - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - - // TEMP: sync transition - if ($this->queryParams['sincetime'] !== null) { - $deleted = array( - "collections" => Zotero_Collections::getDeleteLogKeys( - $this->objectLibraryID, $this->queryParams['sincetime'], true - ), - "items" => Zotero_Items::getDeleteLogKeys( - $this->objectLibraryID, $this->queryParams['sincetime'], true - ), - "searches" => Zotero_Searches::getDeleteLogKeys( - $this->objectLibraryID, $this->queryParams['sincetime'], true - ), - "tags" => Zotero_Tags::getDeleteLogKeys( - $this->objectLibraryID, $this->queryParams['sincetime'], true - ), - "settings" => Zotero_Settings::getDeleteLogKeys( - $this->objectLibraryID, $this->queryParams['sincetime'], true - ), - ); - echo Zotero_Utilities::formatJSON($deleted); - $this->end(); - } - - if ($this->queryParams['since'] === null) { - $this->e400("'since' parameter must be provided"); - } - - $deleted = array( - "collections" => Zotero_Collections::getDeleteLogKeys( - $this->objectLibraryID, $this->queryParams['since'] - ), - "items" => Zotero_Items::getDeleteLogKeys( - $this->objectLibraryID, $this->queryParams['since'] - ), - "searches" => Zotero_Searches::getDeleteLogKeys( - $this->objectLibraryID, $this->queryParams['since'] - ), - "tags" => Zotero_Tags::getDeleteLogKeys( - $this->objectLibraryID, $this->queryParams['since'] - ), - "settings" => Zotero_Settings::getDeleteLogKeys( - $this->objectLibraryID, $this->queryParams['since'] - ) - ); - - echo Zotero_Utilities::formatJSON($deleted); - $this->end(); - } -} - diff --git a/controllers/FullTextController.php b/controllers/FullTextController.php deleted file mode 100644 index dfc1d3f7..00000000 --- a/controllers/FullTextController.php +++ /dev/null @@ -1,160 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class FullTextController extends ApiController { - public function __construct($controllerName, $action, $params) { - parent::__construct($controllerName, $action, $params); - } - - - public function fulltext() { - $this->allowMethods(['GET', 'POST']); - - // Check for general library access - if (!$this->permissions->canAccess($this->objectLibraryID)) { - $this->e403(); - } - - // Multi-item write - if ($this->isWriteMethod()) { - if ($this->apiVersion < 3) { - $this->e405(); - } - - // Check for library write access - if (!$this->permissions->canWrite($this->objectLibraryID)) { - $this->e403("Write access denied"); - } - - $this->requireContentType("application/json"); - - // Make sure library hasn't been modified - $this->checkLibraryIfUnmodifiedSinceVersion(true); - - Zotero_Libraries::updateVersionAndTimestamp($this->objectLibraryID); - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - - $this->queryParams['format'] = 'writereport'; - $obj = $this->jsonDecode($this->body); - - $results = Zotero_FullText::updateMultipleFromJSON( - $obj, - $this->queryParams, - $this->objectLibraryID, - $this->userID, - $this->permissions - ); - - Zotero_API::multiResponse([ - 'action' => $this->action, - 'uri' => $this->uri, - 'results' => $results, - 'requestParams' => $this->queryParams, - 'permissions' => $this->permissions - ]); - } - // Default to ?format=versions for GET - else { - $newer = Zotero_FullText::getNewerInLibrary( - $this->objectLibraryID, - !empty($this->queryParams['since']) ? $this->queryParams['since'] : 0 - ); - - $this->libraryVersion = Zotero_Libraries::getVersion($this->objectLibraryID); - - echo Zotero_Utilities::formatJSON($newer); - } - - $this->end(); - } - - - public function itemContent() { - $this->allowMethods(array('GET', 'PUT')); - - // Check for general library access - if (!$this->permissions->canAccess($this->objectLibraryID)) { - $this->e403(); - } - - if (!$this->singleObject) { - $this->e404(); - } - - if ($this->isWriteMethod()) { - Zotero_DB::beginTransaction(); - - // Check for library write access - if (!$this->permissions->canWrite($this->objectLibraryID)) { - $this->e403("Write access denied"); - } - - Zotero_Libraries::updateVersionAndTimestamp($this->objectLibraryID); - } - - $item = Zotero_Items::getByLibraryAndKey($this->objectLibraryID, $this->objectKey); - if (!$item) { - $this->e404(); - } - - if (!$item->isAttachment() || $item->attachmentLinkMode == 'linked_url') { - $this->e404(); - } - - if ($this->isWriteMethod()) { - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - - if ($this->method == 'PUT') { - $this->requireContentType("application/json"); - - Zotero_FullText::indexItem($item, $this->jsonDecode($this->body)); - Zotero_DB::commit(); - $this->e204(); - } - - $this->e405(); - } - - $data = Zotero_FullText::getItemData($item->libraryID, $item->key); - if (!$data) { - $this->e404(); - } - $this->libraryVersion = $data['version']; - $json = [ - "content" => $data['content'] - ]; - foreach (Zotero_FullText::$metadata as $prop) { - if (!empty($data[$prop])) { - $json[$prop] = $data[$prop]; - } - } - echo Zotero_Utilities::formatJSON($json); - - $this->end(); - } -} diff --git a/controllers/GlobalItemsController.php b/controllers/GlobalItemsController.php deleted file mode 100644 index af7092d6..00000000 --- a/controllers/GlobalItemsController.php +++ /dev/null @@ -1,65 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class GlobalItemsController extends ApiController { - public function globalItems() { - $this->allowMethods(['GET']); - $params = []; - if (!empty($_GET['q'])) { - if (strlen($_GET['q']) < 3) { - $this->e400("Query string must be at least 3 characters length"); - } - $params['q'] = $_GET['q']; - } - else if (!empty($_GET['doi'])) { - $params['doi'] = $_GET['doi']; - } - else if (!empty($_GET['isbn'])) { - $params['isbn'] = $_GET['isbn']; - } - else { - $this->e400("One of the following query parameters must be used: q, doi, isbn"); - } - - $params['start'] = $this->queryParams['start']; - $params['limit'] = $this->queryParams['limit']; - - $result = Zotero_GlobalItems::getGlobalItems($params); - for ($i = 0, $len = sizeOf($result['data']); $i < $len; $i++) { - unset($result['data'][$i]['libraryItems']); - unset($result['data'][$i]['meta']['instanceCount']); - $result['data'][$i]['meta']['numLibraries'] = $result['data'][$i]['meta']['librariesCount']; - unset($result['data'][$i]['meta']['librariesCount']); - } - - header('Content-Type: application/json'); - header('Total-Results: ' . $result['totalResults']); - echo Zotero_Utilities::formatJSON($result['data']); - $this->end(); - } -} diff --git a/controllers/GroupsController.php b/controllers/GroupsController.php deleted file mode 100644 index 6822bea4..00000000 --- a/controllers/GroupsController.php +++ /dev/null @@ -1,541 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class GroupsController extends ApiController { - public function groups() { - $groupID = $this->objectGroupID; - - // - // Add a group - // - if ($this->method == 'POST') { - if (!$this->permissions->isSuper()) { - $this->e403(); - } - - if ($groupID) { - $this->e400("POST requests cannot end with a groupID (did you mean PUT?)"); - } - - try { - $group = @new SimpleXMLElement($this->body); - } - catch (Exception $e) { - $this->e400("$this->method data is not valid XML"); - } - - if ((int) $group['id']) { - $this->e400("POST requests cannot contain a groupID in '" . $this->body . "'"); - } - - $fields = $this->getFieldsFromGroupXML($group); - - Zotero_DB::beginTransaction(); - - try { - $group = new Zotero_Group; - foreach ($fields as $field=>$val) { - $group->$field = $val; - } - $group->save(); - } - catch (Exception $e) { - if (strpos($e->getMessage(), "Invalid") === 0) { - $this->e400($e->getMessage() . " in " . $this->body . "'"); - } - - switch ($e->getCode()) { - case Z_ERROR_GROUP_NAME_UNAVAILABLE: - $this->e400($e->getMessage()); - - default: - $this->handleException($e); - } - } - - $this->queryParams['content'] = array('full'); - $this->responseXML = $group->toAtom($this->queryParams); - - Zotero_DB::commit(); - - $url = Zotero_API::getGroupURI($group); - $this->responseCode = 201; - header("Location: " . $url, false, 201); - $this->end(); - } - - // - // Update a group - // - if ($this->method == 'PUT') { - if (!$this->permissions->isSuper()) { - $this->e403(); - } - - if (!$groupID) { - $this->e400("PUT requests must end with a groupID (did you mean POST?)"); - } - - try { - $group = @new SimpleXMLElement($this->body); - } - catch (Exception $e) { - $this->e400("$this->method data is not valid XML"); - } - - $fields = $this->getFieldsFromGroupXML($group); - - // Group id is optional, but, if it's there, make sure it matches - $id = (string) $group['id']; - if ($id && $id != $groupID) { - $this->e400("Group ID $id does not match group ID $groupID from URI"); - } - - Zotero_DB::beginTransaction(); - - try { - $group = Zotero_Groups::get($groupID); - if (!$group) { - $this->e404("Group $groupID does not exist"); - } - foreach ($fields as $field=>$val) { - $group->$field = $val; - } - - if ($this->ifUnmodifiedSince - && strtotime($group->dateModified) > $this->ifUnmodifiedSince) { - $this->e412(); - } - - $group->save(); - } - catch (Exception $e) { - if (strpos($e->getMessage(), "Invalid") === 0) { - $this->e400($e->getMessage() . " in " . $this->body . "'"); - } - else if ($e->getCode() == Z_ERROR_GROUP_DESCRIPTION_TOO_LONG) { - $this->e400($e->getMessage()); - } - $this->handleException($e); - } - - $this->queryParams['content'] = array('full'); - $this->responseXML = $group->toAtom($this->queryParams); - - Zotero_DB::commit(); - - $this->end(); - } - - - // - // Delete a group - // - if ($this->method == 'DELETE') { - if (!$this->permissions->isSuper()) { - $this->e403(); - } - - if (!$groupID) { - $this->e400("DELETE requests must end with a groupID"); - } - - Zotero_DB::beginTransaction(); - - $group = Zotero_Groups::get($groupID); - if (!$group) { - $this->e404("Group $groupID does not exist"); - } - $group->erase(); - Zotero_DB::commit(); - - header("HTTP/1.1 204 No Content"); - exit; - } - - - // - // View one or more groups - // - - // Single group - if ($groupID) { - if (!$this->permissions->canAccess($this->objectLibraryID)) { - $this->e403(); - } - $group = Zotero_Groups::get($groupID); - if (!$group) { - $this->e404("Group not found"); - } - if ($this->apiVersion >= 3) { - $this->libraryVersion = $group->version; - } - else { - header("ETag: " . $group->etag); - } - - if ($this->method == 'HEAD') { - $this->end(); - } - - switch ($this->queryParams['format']) { - case 'atom': - $this->responseXML = $group->toAtom($this->queryParams); - break; - - case 'json': - $json = $group->toResponseJSON($this->queryParams); - echo Zotero_Utilities::formatJSON($json); - break; - - default: - throw new Exception("Unexpected format '" . $this->queryParams['format'] . "'"); - } - } - - // Multiple groups - else { - if ($this->objectUserID) { - $title = Zotero_Users::getUsername($this->objectUserID) . "’s Groups"; - } - else { - // For now, only root can do unrestricted group searches - if (!$this->permissions->isSuper()) { - $this->e403(); - } - - $title = "Groups"; - } - - if ($this->queryParams['start'] > 1000) { - $this->e503(); - } - - try { - $results = Zotero_Groups::getAllAdvanced($this->objectUserID, $this->queryParams, $this->permissions); - } - catch (Exception $e) { - switch ($e->getCode()) { - case Z_ERROR_INVALID_GROUP_TYPE: - $this->e400($e->getMessage()); - } - throw ($e); - } - - $options = [ - 'action' => $this->action, - 'uri' => $this->uri, - 'results' => $results, - 'requestParams' => $this->queryParams, - 'permissions' => $this->permissions, - 'head' => $this->method == 'HEAD' - ]; - switch ($this->queryParams['format']) { - case 'atom': - $this->responseXML = Zotero_API::multiResponse(array_merge($options, [ - 'title' => $title - ])); - break; - - case 'json': - Zotero_API::multiResponse($options); - break; - - case 'etags': - case 'versions': - $prop = substr($this->queryParams['format'], 0, -1); // remove 's' - $newResults = []; - foreach ($results['results'] as $group) { - $newResults[$group->id] = $group->$prop; - } - $options['results']['results'] = $newResults; - Zotero_API::multiResponse($options, 'versions'); - break; - - default: - throw new Exception("Unexpected format '" . $this->queryParams['format'] . "'"); - } - } - - $this->end(); - } - - - public function groupUsers() { - // For now, only allow root and user access - if (!$this->permissions->isSuper()) { - $this->e403(); - } - - $groupID = $this->scopeObjectID; - $userID = $this->objectID; - - $group = Zotero_Groups::get($groupID); - if (!$group) { - $this->e404("Group $groupID does not exist"); - } - - // Add multiple users to group - if ($this->method == 'POST') { - if ($userID) { - $this->e400("POST requests cannot end with a userID (did you mean PUT?)"); - } - - // Body can contain multiple blocks, so stuff in root element - try { - $xml = @new SimpleXMLElement("" . $this->body . ""); - } - catch (Exception $e) { - $this->e400("$this->method data is not valid XML"); - } - - $addedUserIDs = array(); - - Zotero_DB::beginTransaction(); - - foreach ($xml->user as $user) { - $id = (int) $user['id']; - $role = (string) $user['role']; - - if (!$id) { - $this->e400("User ID not provided in '" . $user->asXML() . "'"); - } - - if (!$role) { - $this->e400("Role not provided in '" . $user->asXML() . "'"); - } - - try { - $added = $group->addUser($id, $role); - } - catch (Exception $e) { - if (strpos($e->getMessage(), "Invalid role") === 0) { - $this->e400("Invalid role '$role' in " . $user->asXML() . "'"); - } - $this->handleException($e); - } - - if ($added) { - $addedUserIDs[] = $id; - } - } - - // Response after adding - $entries = array(); - foreach ($addedUserIDs as $addedUserID) { - $entries[] = $group->memberToAtom($addedUserID); - } - - $title = "Users added to group '$group->name'"; - $this->responseXML = Zotero_Atom::createAtomFeed( - 'groupUsers', - $title, - $this->uri, - $entries, - null, - $this->queryParams, - $this->permissions - ); - - Zotero_DB::commit(); - - $this->end(); - } - - // Add a single user to group - if ($this->method == 'PUT') { - if (!$userID) { - $this->e400("PUT requests must end with a userID (did you mean POST?)"); - } - - try { - $user = @new SimpleXMLElement($this->body); - } - catch (Exception $e) { - $this->e400("$this->method data is not valid XML"); - } - - $id = (int) $user['id']; - $role = (string) $user['role']; - - // User id is optional, but, if it's there, make sure it matches - if ($id && $id != $userID) { - $this->e400("User ID $id does not match user ID $userID from URI"); - } - - if (!$role) { - $this->e400("Role not provided in '$this->body'"); - } - - Zotero_DB::beginTransaction(); - - $changedUserIDs = array(); - - try { - if ($role == 'owner') { - if ($userID != $group->ownerUserID) { - $changedUserIDs[] = $group->ownerUserID; - $group->ownerUserID = $userID; - $group->save(); - $changedUserIDs[] = $userID; - } - } - else { - if ($group->hasUser($userID)) { - try { - $updated = $group->updateUser($userID, $role); - } - catch (Exception $e) { - switch ($e->getCode()) { - case Z_ERROR_CANNOT_DELETE_GROUP_OWNER: - $this->e400($e->getMessage()); - - default: - $this->handleException($e); - } - } - if ($updated) { - $changedUsersIDs[] = $userID; - } - } - else { - $added = $group->addUser($userID, $role); - if ($added) { - $changedUserIDs[] = $userID; - } - } - } - } - catch (Exception $e) { - if (strpos($e->getMessage(), "Invalid role") === 0) { - $this->e400("Invalid role '$role' in '$this->body'"); - } - $this->handleException($e); - } - - // Response after adding - $entries = array(); - foreach ($changedUserIDs as $changedUserID) { - $entries[] = $group->memberToAtom($changedUserID); - } - - $title = "Users changed in group '$group->name'"; - $this->responseXML = Zotero_Atom::createAtomFeed( - 'groupUsers', - $title, - $this->uri, - $entries, - null, - $this->queryParams, - $this->permissions - ); - - Zotero_DB::commit(); - - $this->end(); - } - - - if ($this->method == 'DELETE') { - if (!$userID) { - $this->e400("DELETE requests must end with a userID"); - } - - Zotero_DB::beginTransaction(); - - try { - $group->removeUser($userID); - } - catch (Exception $e) { - switch ($e->getCode()) { - case Z_ERROR_CANNOT_DELETE_GROUP_OWNER: - $this->e400($e->getMessage()); - - case Z_ERROR_USER_NOT_GROUP_MEMBER: - $this->e404($e->getMessage()); - - default: - $this->handleException($e); - } - } - - Zotero_DB::commit(); - - header("HTTP/1.1 204 No Content"); - exit; - } - - // Single user - if ($userID) { - $this->responseXML = $group->memberToAtom($userID); - $this->end(); - } - - // Multiple users - $title = "Members of '$group->name'"; - - $entries = array(); - $memberIDs = array_merge( - array($group->ownerUserID), - $group->getAdmins(), - $group->getMembers() - ); - foreach ($memberIDs as $userID) { - $entries[] = $group->memberToAtom($userID); - } - $totalResults = sizeOf($entries); - - $this->responseXML = Zotero_Atom::createAtomFeed( - 'groupUsers', - $title, - $this->uri, - $entries, - $totalResults, - $this->queryParams, - $this->permissions - ); - - $this->end(); - } - - - protected function getFieldsFromGroupXML(SimpleXMLElement $group) { - $fields = array(); - $fields['ownerUserID'] = (int) $group['owner']; - $fields['name'] = (string) $group['name']; - $fields['type'] = (string) $group['type']; - $fields['libraryEditing'] = (string) $group['libraryEditing']; - $fields['libraryReading'] = (string) $group['libraryReading']; - $fields['fileEditing'] = (string) $group['fileEditing']; - $fields['description'] = (string) $group->description; - $fields['url'] = (string) $group->url; - $fields['hasImage'] = (bool) (int) $group['hasImage']; - - return $fields; - } -} diff --git a/controllers/ItemsController.php b/controllers/ItemsController.php deleted file mode 100644 index 0a1d86df..00000000 --- a/controllers/ItemsController.php +++ /dev/null @@ -1,1228 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class ItemsController extends ApiController { - public function items() { - if ($this->isWriteMethod()) { - // Check for library write access - if (!$this->permissions->canWrite($this->objectLibraryID)) { - $this->e403("Write access denied"); - } - - // Make sure library hasn't been modified - if (!$this->singleObject) { - $libraryTimestampChecked = $this->checkLibraryIfUnmodifiedSinceVersion(); - } - - // We don't update the library version in file mode, because currently - // to avoid conflicts in the client the timestamp can't change - // when the client updates file metadata - if (!$this->fileMode) { - Zotero_Libraries::updateVersionAndTimestamp($this->objectLibraryID); - } - } - - $itemIDs = array(); - $itemKeys = array(); - $results = array(); - $title = ""; - - if ($this->objectGlobalItemID) { - $id = $this->objectGlobalItemID; - $libraryItems = Zotero_GlobalItems::getGlobalItemLibraryItems($id); - if (!$libraryItems) { - $this->e404(); - } - // TODO: Improve pagination - // Pagination isn't reliable here, because we - // don't know if library and key exist and if we have permissions - // to access it, until we actually query specific library and key. - // Empty object placeholders should be returned where item - // retrieval fails, or otherwise all items before 'start' must be fetched - //$start = $this->queryParams['start']; - $start = 0; - $limit = $this->queryParams['limit']; - - // Group items by libraryID to later query all library's items at once - $groupedLibraryItems = []; - for ($i = 0, $len = sizeOf($libraryItems); $i < $len; $i++) { - list($libraryID, $key) = $libraryItems[$i]; - $groupedLibraryItems[$libraryID][] = $key; - } - - $allResults = ['results' => [], 'total' => 0]; - foreach ($groupedLibraryItems as $libraryID => $keys) { - if (!$this->permissions->canAccess($libraryID)) { - continue; - } - - $remaining = $limit - sizeOf($allResults['results']); - if (!$remaining) { - // If not adding more items, add approximate total based on number of items from - // libraryItems array. These might not all exist if they've been deleted recently, - // but we don't want to keep searching for all items after reaching the limit. - $allResults['total'] += sizeOf($keys); - continue; - } - - // Do not pass $this->queryParams directly to prevent - // other query parameters from influencing Zotero_Items::search - $params = [ - 'format' => $this->queryParams['format'], - 'itemKey' => $keys - ]; - $results = Zotero_Items::search( - $libraryID, - false, - $params - ); - $allResults['results'] = array_merge( - $allResults['results'], - array_slice($results['results'], 0, $remaining) - ); - $allResults['total'] += $results['total']; - } - $this->generateMultiResponse($allResults); - $this->end(); - } - - // - // Single item - // - if ($this->singleObject) { - if ($this->fileMode) { - if ($this->fileView) { - $this->allowMethods(array('HEAD', 'GET', 'POST')); - } - else { - $this->allowMethods(array('HEAD', 'GET', 'PUT', 'POST', 'PATCH')); - } - } - else { - $this->allowMethods(array('HEAD', 'GET', 'PUT', 'PATCH', 'DELETE')); - } - - if (!Zotero_ID::isValidKey($this->objectKey)) { - $this->e404(); - } - - $item = Zotero_Items::getByLibraryAndKey($this->objectLibraryID, $this->objectKey); - if ($item) { - // If no access to the item, don't show that it exists - if (!$this->permissions->canAccessObject($item)) { - $this->e404(); - } - - // Don't show an item in publications that doesn't belong there, even if user has - // access to it - if ($this->publications - && ((!$this->legacyPublications && !$item->inPublications) - || $item->deleted)) { - $this->e404(); - } - - // Make sure URL libraryID matches item libraryID - if ($this->objectLibraryID != $item->libraryID) { - $this->e404(); - } - - // File access mode - if ($this->fileMode) { - $this->_handleFileRequest($item); - } - - if ($this->scopeObject) { - switch ($this->scopeObject) { - // Remove item from collection - case 'collections': - $this->allowMethods(array('DELETE')); - - $collection = Zotero_Collections::getByLibraryAndKey($this->objectLibraryID, $this->scopeObjectKey); - if (!$collection) { - $this->e404("Collection not found"); - } - - if (!$collection->hasItem($item->id)) { - $this->e404("Item not found in collection"); - } - - $collection->removeItem($item->id); - $this->e204(); - - default: - $this->e400(); - } - } - } - else { - if ($this->isWriteMethod() && $this->fileMode) { - $this->e404(); - } - - // Possibly temporary workaround to block unnecessary full syncs - if ($this->fileMode && $this->httpAuth && $this->method == 'POST') { - // If > 2 requests for missing file, trigger a full sync via 404 - $cacheKey = "apiMissingFile_" - . $this->objectLibraryID . "_" - . $this->objectKey; - $set = Z_Core::$MC->get($cacheKey); - if (!$set) { - Z_Core::$MC->set($cacheKey, 1, 86400); - } - else if ($set < 2) { - Z_Core::$MC->increment($cacheKey); - } - else { - Z_Core::$MC->delete($cacheKey); - $this->e404("A file sync error occurred. Please sync again."); - } - $this->e500("A file sync error occurred. Please sync again."); - } - } - - if ($this->isWriteMethod()) { - $item = $this->handleObjectWrite('item', $item ? $item : null); - - if ($this->apiVersion < 2 - && ($this->method == 'PUT' || $this->method == 'PATCH')) { - $this->queryParams['format'] = 'atom'; - $this->queryParams['content'] = ['json']; - } - } - - if (!$item) { - $this->e404("Item does not exist"); - } - - $this->libraryVersion = $item->version; - - if ($this->method == 'HEAD') { - $this->end(); - } - - // Display item - switch ($this->queryParams['format']) { - case 'atom': - $this->responseXML = Zotero_Items::convertItemToAtom( - $item, $this->queryParams, $this->permissions - ); - break; - - case 'bib': - echo Zotero_Cite::getBibliographyFromCitationServer(array($item), $this->queryParams); - break; - - case 'csljson': - // TODO: Use in APIv4 - //$json = Zotero_Cite::getJSONFromItems([$item], true)['items'][0]; - $json = Zotero_Cite::getJSONFromItems(array($item), true); - echo Zotero_Utilities::formatJSON($json); - break; - - case 'json': - $json = $item->toResponseJSON($this->queryParams, $this->permissions); - echo Zotero_Utilities::formatJSON($json); - break; - - default: - $export = Zotero_Translate::doExport([$item], $this->queryParams); - $this->queryParams['format'] = null; - header("Content-Type: " . $export['mimeType']); - echo $export['body']; - break; - } - } - - // - // Multiple items - // - else { - $this->allowMethods(array('HEAD', 'GET', 'POST', 'DELETE')); - - // Check for general library access - if (!$this->publications && !$this->permissions->canAccess($this->objectLibraryID)) { - $this->e403(); - } - - if ($this->publications) { - // Disabled until it actually works - /*// Include ETag in My Publications (or, in the future, public collections) - $this->etag = Zotero_Publications::getETag($this->objectUserID); - - // Return 304 if ETag matches - if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] == $this->etag) { - $this->e304(); - }*/ - - // TEMP: Remove after integrated publications upgrade - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - } - // Last-Modified-Version otherwise - else { - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - } - - $includeTrashed = $this->queryParams['includeTrashed']; - - if ($this->scopeObject) { - $this->allowMethods(array('GET', 'POST')); - - switch ($this->scopeObject) { - case 'collections': - // TEMP - if (Zotero_ID::isValidKey($this->scopeObjectKey)) { - $collection = Zotero_Collections::getByLibraryAndKey($this->objectLibraryID, $this->scopeObjectKey); - } - else { - $collection = false; - } - if (!$collection) { - // If old collectionID, redirect - if ($this->method == 'GET' && Zotero_Utilities::isPosInt($this->scopeObjectKey)) { - $collection = Zotero_Collections::get($this->objectLibraryID, $this->scopeObjectKey); - if ($collection) { - $qs = !empty($_SERVER['QUERY_STRING']) ? '?' . $_SERVER['QUERY_STRING'] : ''; - $base = Zotero_API::getCollectionURI($collection); - $suffix = $this->subset == 'top' ? '/top' : ''; - $this->redirect($base . "/items$suffix" . $qs, 301); - } - } - - $this->e404("Collection not found"); - } - - // Add items to collection - if ($this->method == 'POST') { - $itemKeys = explode(' ', $this->body); - $itemIDs = array(); - foreach ($itemKeys as $key) { - try { - $item = Zotero_Items::getByLibraryAndKey($this->objectLibraryID, $key); - } - catch (Exception $e) { - if ($e->getCode() == Z_ERROR_OBJECT_LIBRARY_MISMATCH) { - $item = false; - } - else { - throw ($e); - } - } - - if (!$item) { - throw new Exception("Item '$key' not found in library", Z_ERROR_INVALID_INPUT); - } - - if ($item->getSource()) { - throw new Exception("Child items cannot be added to collections directly", Z_ERROR_INVALID_INPUT); - } - $itemIDs[] = $item->id; - } - $collection->addItems($itemIDs); - - $this->e204(); - } - - if ($this->subset == 'top' || $this->apiVersion < 2) { - $title = "Top-Level Items in Collection ‘" . $collection->name . "’"; - $itemIDs = $collection->getItems(); - } - else { - $title = "Items in Collection ‘" . $collection->name . "’"; - $itemIDs = $collection->getItems(true); - } - break; - - case 'tags': - if ($this->apiVersion >= 2) { - $this->e404(); - } - - $this->allowMethods(array('GET')); - - $tagIDs = Zotero_Tags::getIDs($this->objectLibraryID, $this->scopeObjectName); - 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 . "’"; - } - $itemKeys = array_merge($itemKeys, $tag->getLinkedItems(true)); - } - $itemKeys = array_unique($itemKeys); - - break; - - default: - $this->e404(); - } - } - else { - // Top-level items - if ($this->subset == 'top') { - $this->allowMethods(array('GET')); - - $title = "Top-Level Items"; - $results = Zotero_Items::search( - $this->objectLibraryID, - true, - $this->queryParams, - $includeTrashed, - $this->permissions - ); - } - // Deleted items - else if ($this->subset == 'trash') { - $this->allowMethods(array('GET')); - - $title = "Deleted Items"; - $this->queryParams['trashedItemsOnly'] = true; - $includeTrashed = true; - $results = Zotero_Items::search( - $this->objectLibraryID, - false, - $this->queryParams, - $includeTrashed, - $this->permissions - ); - } - else if ($this->subset == 'children') { - $item = Zotero_Items::getByLibraryAndKey($this->objectLibraryID, $this->objectKey); - if (!$item) { - $this->e404("Item not found"); - } - - // Don't show child items in publications mode of an item not in publications - if ($this->publications && !$item->inPublications) { - $this->e404("Item not found"); - } - - if ($item->isAttachment()) { - $this->e400("/children cannot be called on attachment items"); - } - if ($item->isNote()) { - $this->e400("/children cannot be called on note items"); - } - if ($item->getSource()) { - $this->e400("/children cannot be called on child items"); - } - - // Create new child items - if ($this->method == 'POST') { - if ($this->apiVersion >= 2) { - $this->allowMethods(array('GET')); - } - - Zotero_DB::beginTransaction(); - - $obj = $this->jsonDecode($this->body); - $results = Zotero_Items::updateMultipleFromJSON( - $obj, - $this->queryParams, - $this->objectLibraryID, - $this->userID, - $this->permissions, - $libraryTimestampChecked ? 0 : 1, - $item - ); - - Zotero_DB::commit(); - - if ($cacheKey = $this->getWriteTokenCacheKey()) { - Z_Core::$MC->set($cacheKey, true, $this->writeTokenCacheTime); - } - - $uri = Zotero_API::getItemsURI($this->objectLibraryID); - $keys = array_merge( - get_object_vars($results['success']), - get_object_vars($results['unchanged']) - ); - $queryString = "itemKey=" - . urlencode(implode(",", $keys)) - . "&format=atom&content=json&order=itemKeyList&sort=asc"; - if ($this->apiKey) { - $queryString .= "&key=" . $this->apiKey; - } - $uri .= "?" . $queryString; - $this->queryParams = Zotero_API::parseQueryParams($queryString, $this->action, false); - $this->responseCode = 201; - - $title = "Items"; - $results = Zotero_Items::search( - $this->objectLibraryID, - false, - $this->queryParams, - $includeTrashed, - $this->permissions - ); - } - // Display items - else { - $title = "Child Items of ‘" . $item->getDisplayTitle() . "’"; - $notes = $item->getNotes(); - $attachments = $item->getAttachments(); - $itemIDs = array_merge($notes, $attachments); - } - } - // All items - else { - // Create new items - if ($this->method == 'POST') { - $this->queryParams['format'] = 'writereport'; - - $obj = $this->jsonDecode($this->body); - - // Server-side translation - if (isset($obj->url)) { - if ($this->apiVersion == 1) { - Zotero_DB::beginTransaction(); - } - - $token = $this->getTranslationToken($obj); - - $results = Zotero_Items::addFromURL( - $obj, - $this->queryParams, - $this->objectLibraryID, - $this->userID, - $this->permissions, - $token - ); - - if ($this->apiVersion == 1) { - Zotero_DB::commit(); - } - // Multiple choices - if ($results instanceof stdClass) { - $this->queryParams['format'] = null; - header("Content-Type: application/json"); - if ($this->queryParams['v'] >= 2) { - echo Zotero_Utilities::formatJSON([ - 'url' => $obj->url, - 'token' => $token, - 'items' => $results->select - ]); - } - else { - echo Zotero_Utilities::formatJSON($results->select); - } - $this->e300(); - } - // Error from translation server - else if (is_int($results)) { - switch ($results) { - case 501: - $this->e501("No translators found for URL"); - break; - - default: - $this->e500("Error translating URL"); - } - } - // In v1, return data for saved items - else if ($this->apiVersion == 1) { - $uri = Zotero_API::getItemsURI($this->objectLibraryID); - $keys = array_merge( - get_object_vars($results['success']), - get_object_vars($results['unchanged']) - ); - $queryString = "itemKey=" - . urlencode(implode(",", $keys)) - . "&format=atom&content=json&order=itemKeyList&sort=asc"; - if ($this->apiKey) { - $queryString .= "&key=" . $this->apiKey; - } - $uri .= "?" . $queryString; - $this->queryParams = Zotero_API::parseQueryParams($queryString, $this->action, false); - $this->responseCode = 201; - - $title = "Items"; - $results = Zotero_Items::search( - $this->objectLibraryID, - false, - $this->queryParams, - $includeTrashed, - $this->permissions - ); - } - // Otherwise return write status report - } - // Uploaded items - else { - if ($this->apiVersion < 2) { - Zotero_DB::beginTransaction(); - } - - $results = Zotero_Items::updateMultipleFromJSON( - $obj, - $this->queryParams, - $this->objectLibraryID, - $this->userID, - $this->permissions, - $libraryTimestampChecked ? 0 : 1, - null - ); - - if ($this->apiVersion < 2) { - Zotero_DB::commit(); - - $uri = Zotero_API::getItemsURI($this->objectLibraryID); - $keys = array_merge( - get_object_vars($results['success']), - get_object_vars($results['unchanged']) - ); - $queryString = "itemKey=" - . urlencode(implode(",", $keys)) - . "&format=atom&content=json&order=itemKeyList&sort=asc"; - if ($this->apiKey) { - $queryString .= "&key=" . $this->apiKey; - } - $uri .= "?" . $queryString; - $this->queryParams = Zotero_API::parseQueryParams($queryString, $this->action, false); - $this->responseCode = 201; - - $title = "Items"; - $results = Zotero_Items::search( - $this->objectLibraryID, - false, - $this->queryParams, - $includeTrashed, - $this->permissions - ); - } - } - - if ($cacheKey = $this->getWriteTokenCacheKey()) { - Z_Core::$MC->set($cacheKey, true, $this->writeTokenCacheTime); - } - } - // Delete items - else if ($this->method == 'DELETE') { - Zotero_DB::beginTransaction(); - foreach ($this->queryParams['itemKey'] as $itemKey) { - Zotero_Items::delete($this->objectLibraryID, $itemKey); - } - Zotero_DB::commit(); - $this->e204(); - } - // Display items - else { - $title = "Items"; - $results = Zotero_Items::search( - $this->objectLibraryID, - false, - $this->queryParams, - $includeTrashed, - $this->permissions - ); - } - } - } - - if ($itemIDs || $itemKeys) { - if ($itemIDs) { - $this->queryParams['itemIDs'] = $itemIDs; - } - if ($itemKeys) { - $this->queryParams['itemKey'] = $itemKeys; - } - $results = Zotero_Items::search( - $this->objectLibraryID, - false, - $this->queryParams, - $includeTrashed, - $this->permissions - ); - } - - if ($this->queryParams['format'] == 'bib') { - $maxBibItems = Zotero_API::MAX_BIBLIOGRAPHY_ITEMS; - if ($results['total'] > $maxBibItems) { - $this->e413("Cannot generate bibliography with more than $maxBibItems items"); - } - } - - $this->generateMultiResponse($results, $title); - } - - $this->end(); - } - - - private function generateMultiResponse($results, $title='') { - $options = [ - 'action' => $this->action, - 'uri' => $this->uri, - 'results' => $results, - 'requestParams' => $this->queryParams, - 'permissions' => $this->permissions, - 'head' => $this->method == 'HEAD' - ]; - $format = $this->queryParams['format']; - - switch ($format) { - case 'atom': - $this->responseXML = Zotero_API::multiResponse( - array_merge( - $options, - [ - 'title' => $this->getFeedNamePrefix($this->objectLibraryID) . $title - ] - ) - ); - break; - - case 'bib': - if ($this->method == 'HEAD') { - break; - } - if (isset($results['results'])) { - echo Zotero_Cite::getBibliographyFromCitationServer($results['results'], $this->queryParams); - } - break; - - case 'csljson': - case 'json': - case 'keys': - case 'versions': - case 'writereport': - Zotero_API::multiResponse($options); - break; - - default: - if (Zotero_Translate::isExportFormat($format)) { - Zotero_API::multiResponse($options); - $this->queryParams['format'] = null; - } - else { - throw new Exception("Unexpected format '$format'"); - } - } - } - - - /** - * Handle S3 request - * - * Permission-checking provided by items() - */ - private function _handleFileRequest($item) { - if (!$this->permissions->canAccess($this->objectLibraryID, 'files') - // Check access on specific item, for My Publications files - && !$this->permissions->canAccessObject($item)) { - $this->e403(); - } - - $this->allowMethods(array('HEAD', 'GET', 'POST', 'PATCH')); - - if (!$item->isAttachment()) { - $this->e400("Item is not an attachment"); - } - - // File info for 4.0 client sync - // - // Use of HEAD method was discontinued after 2.0.8/2.1b1 due to - // compatibility problems with proxies and security software - if ($this->method == 'GET' && $this->fileMode == 'info') { - $info = Zotero_Storage::getLocalFileItemInfo($item); - if (!$info) { - $this->e404(); - } - StatsD::increment("storage.info", 1); - /* - header("Last-Modified: " . gmdate('r', $info['uploaded'])); - header("Content-Type: " . $info['type']); - */ - header("Content-Length: " . $info['size']); - header("ETag: " . $info['hash']); - header("X-Zotero-Filename: " . $info['filename']); - header("X-Zotero-Modification-Time: " . $info['mtime']); - header("X-Zotero-Compressed: " . ($info['zip'] ? 'Yes' : 'No')); - header_remove("X-Powered-By"); - $this->end(); - } - - // File viewing/download - // - // TEMP: allow POST for snapshot viewing until using session auth - else if ($this->method == 'GET') { - $info = Zotero_Storage::getLocalFileItemInfo($item); - if (!$info) { - $this->e404(); - } - - // File viewing - if ($this->fileView) { - $url = Zotero_Attachments::getTemporaryURL($item, !empty($_GET['int'])); - if (!$url) { - $this->e500(); - } - StatsD::increment("storage.view", 1); - $this->redirect($url); - exit; - } - - // File download - $url = Zotero_Storage::getDownloadURL($item, 60); - if (!$url) { - $this->e404(); - } - - // Provide some headers to let 5.0 client skip download - header("Zotero-File-Modification-Time: {$info['mtime']}"); - header("Zotero-File-MD5: {$info['hash']}"); - header("Zotero-File-Size: {$info['size']}"); - header("Zotero-File-Compressed: " . ($info['zip'] ? 'Yes' : 'No')); - - StatsD::increment("storage.download", 1); - Zotero_Storage::logDownload( - $item, - // TODO: support anonymous download if necessary - $this->userID, - IPAddress::getIP() - ); - $this->redirect($url); - exit; - } - - else if ($this->method == 'POST' || $this->method == 'PATCH') { - if (!$item->isImportedAttachment()) { - $this->e400("Cannot upload file for linked file/URL attachment item"); - } - - $libraryID = $item->libraryID; - $type = Zotero_Libraries::getType($libraryID); - if ($type == 'group') { - $groupID = Zotero_Groups::getGroupIDFromLibraryID($libraryID); - $group = Zotero_Groups::get($groupID); - if (!$group->userCanEditFiles($this->userID)) { - $this->e403("You do not have file editing access"); - } - } - else { - $group = null; - } - - // If not the 4.0 client, require If-Match or If-None-Match - if (!$this->httpAuth) { - if (empty($_SERVER['HTTP_IF_MATCH']) && empty($_SERVER['HTTP_IF_NONE_MATCH'])) { - $this->e428("If-Match/If-None-Match header not provided"); - } - - if (!empty($_SERVER['HTTP_IF_MATCH'])) { - if (!preg_match('/^"?([a-f0-9]{32})"?$/', $_SERVER['HTTP_IF_MATCH'], $matches)) { - $this->e400("Invalid ETag in If-Match header"); - } - - if (!$item->attachmentStorageHash) { - $this->e412("If-Match set but file does not exist"); - } - - if ($item->attachmentStorageHash != $matches[1]) { - $this->libraryVersion = $item->version; - $this->libraryVersionOnFailure = true; - $this->e412("ETag does not match current version of file"); - } - } - else { - if ($_SERVER['HTTP_IF_NONE_MATCH'] != "*") { - $this->e400("Invalid value for If-None-Match header"); - } - - if ($item->attachmentStorageHash) { - $this->libraryVersion = $item->version; - $this->libraryVersionOnFailure = true; - $this->e412("If-None-Match: * set but file exists"); - } - } - } - - // - // Upload authorization - // - if (!isset($_POST['update']) && !isset($_REQUEST['upload'])) { - $info = new Zotero_StorageFileInfo; - - // Validate upload metadata - if (empty($_REQUEST['md5'])) { - $this->e400('MD5 hash not provided'); - } - if (!preg_match('/[abcdefg0-9]{32}/', $_REQUEST['md5'])) { - $this->e400('Invalid MD5 hash'); - } - if (!isset($_REQUEST['filename']) || $_REQUEST['filename'] === "") { - $this->e400('Filename not provided'); - } - - // Multi-file upload - // - // For ZIP files, the filename and hash of the ZIP file are different from those - // of the main file. We use the former for S3, and we store the latter in the - // upload log to set the attachment metadata with them on file registration. - if (!empty($_REQUEST['zipMD5'])) { - if (!preg_match('/[abcdefg0-9]{32}/', $_REQUEST['zipMD5'])) { - $this->e400('Invalid ZIP MD5 hash'); - } - if (empty($_REQUEST['zipFilename'])) { - $this->e400('ZIP filename not provided'); - } - $info->zip = true; - $info->hash = $_REQUEST['zipMD5']; - $info->filename = $_REQUEST['zipFilename']; - $info->itemFilename = $_REQUEST['filename']; - $info->itemHash = $_REQUEST['md5']; - } - else if (!empty($_REQUEST['zipFilename'])) { - $this->e400('ZIP MD5 hash not provided'); - } - // Single-file upload - else { - $info->zip = !empty($_REQUEST['zip']); - - $info->filename = $_REQUEST['filename']; - $info->hash = $_REQUEST['md5']; - } - - if (empty($_REQUEST['mtime'])) { - $this->e400('File modification time not provided'); - } - $info->mtime = $_REQUEST['mtime']; - - if (!isset($_REQUEST['filesize'])) { - $this->e400('File size not provided'); - } - $info->size = $_REQUEST['filesize']; - if (!is_numeric($info->size)) { - $this->e400("Invalid file size"); - } - // TEMP: Until the client supports multi-part upload - if ($info->size > 5000000000) { - $this->e400("Files above 5 GB are not currently supported"); - } - - $info->contentType = isset($_REQUEST['contentType']) ? $_REQUEST['contentType'] : null; - if (!preg_match("/^[a-zA-Z0-9\-\/]+$/", $info->contentType)) { - $info->contentType = null; - } - - $info->charset = isset($_REQUEST['charset']) ? $_REQUEST['charset'] : null; - if (!preg_match("/^[a-zA-Z0-9\-]+$/", $info->charset)) { - $info->charset = null; - } - - $contentTypeHeader = $info->contentType . (($info->contentType && $info->charset) ? "; charset=" . $info->charset : ""); - - // Reject file if it would put account over quota - if ($group) { - $quota = Zotero_Storage::getEffectiveUserQuota($group->ownerUserID); - $usage = Zotero_Storage::getUserUsage($group->ownerUserID); - } - else { - $quota = Zotero_Storage::getEffectiveUserQuota($this->objectUserID); - $usage = Zotero_Storage::getUserUsage($this->objectUserID); - } - $total = $usage['total']; - $fileSizeMB = round($info->size / 1024 / 1024, 1); - if ($total + $fileSizeMB > $quota) { - StatsD::increment("storage.upload.quota", 1); - $this->e413("File would exceed quota ($total + $fileSizeMB > $quota)"); - } - - Zotero_DB::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE"); - Zotero_DB::beginTransaction(); - - // See if file exists with this filename - $localInfo = Zotero_Storage::getLocalFileInfo($info); - if ($localInfo) { - $storageFileID = $localInfo['storageFileID']; - - // Verify file size - if ($localInfo['size'] != $info->size) { - throw new Exception( - "Specified file size incorrect for existing file " - . $info->hash . "/" . $info->filename - . " ({$localInfo['size']} != {$info->size})" - ); - } - } - // If not found, see if there's a copy with a different name - else { - $oldStorageFileID = Zotero_Storage::getFileByHash($info->hash, $info->zip); - if ($oldStorageFileID) { - // Verify file size - $localInfo = Zotero_Storage::getFileInfoByID($oldStorageFileID); - if ($localInfo['size'] != $info->size) { - throw new Exception( - "Specified file size incorrect for duplicated file " - . $info->hash . "/" . $info->filename - . " ({$localInfo['size']} != {$info->size})" - ); - } - - // Create new file on S3 with new name - $storageFileID = Zotero_Storage::duplicateFile( - $oldStorageFileID, - $info->filename, - $info->zip, - $contentTypeHeader - ); - } - } - - // If we already have a file, add/update storageFileItems row and stop - if (!empty($storageFileID)) { - Zotero_Storage::updateFileItemInfo($item, $storageFileID, $info, $this->httpAuth); - Zotero_DB::commit(); - - StatsD::increment("storage.upload.existing", 1); - - if ($this->httpAuth) { - $this->queryParams['format'] = null; - header('Content-Type: application/xml'); - echo ""; - } - else { - $this->queryParams['format'] = null; - header('Content-Type: application/json'); - $this->libraryVersion = $item->version; - echo json_encode(array('exists' => 1)); - } - $this->end(); - } - - Zotero_DB::commit(); - - // Add request to upload queue - $uploadKey = Zotero_Storage::queueUpload($this->userID, $info); - // User over queue limit - if (!$uploadKey) { - header('Retry-After: ' . Zotero_Storage::$uploadQueueTimeout); - if ($this->httpAuth) { - $this->e413("Too many queued uploads"); - } - else { - $this->e429("Too many queued uploads"); - } - } - - StatsD::increment("storage.upload.new", 1); - - // Output XML for client requests (which use HTTP Auth) - if ($this->httpAuth) { - $params = Zotero_Storage::generateUploadPOSTParams($item, $info, true); - - $this->queryParams['format'] = null; - header('Content-Type: application/xml'); - $xml = new SimpleXMLElement(''); - $xml->url = Zotero_Storage::getUploadBaseURL(); - $xml->key = $uploadKey; - foreach ($params as $key=>$val) { - $xml->params->$key = $val; - } - echo $xml->asXML(); - } - // Output JSON for API requests - else { - if (!empty($_REQUEST['params']) && $_REQUEST['params'] == "1") { - $params = array( - "url" => Zotero_Storage::getUploadBaseURL(), - "params" => array() - ); - foreach (Zotero_Storage::generateUploadPOSTParams($item, $info) as $key=>$val) { - $params['params'][$key] = $val; - } - } - else { - $params = Zotero_Storage::getUploadPOSTData($item, $info); - } - - $params['uploadKey'] = $uploadKey; - - $this->queryParams['format'] = null; - header('Content-Type: application/json'); - echo json_encode($params); - } - exit; - } - - // - // API partial upload and post-upload file registration - // - if (isset($_REQUEST['upload'])) { - $uploadKey = $_REQUEST['upload']; - - if (!$uploadKey) { - $this->e400("Upload key not provided"); - } - - $info = Zotero_Storage::getUploadInfo($uploadKey); - if (!$info) { - $this->e400("Upload key not found"); - } - - // Partial upload - if ($this->method == 'PATCH') { - if (empty($_REQUEST['algorithm'])) { - throw new Exception("Algorithm not specified", Z_ERROR_INVALID_INPUT); - } - - $storageFileID = Zotero_Storage::patchFile($item, $info, $_REQUEST['algorithm'], $this->body); - } - // Full upload - else { - $remoteInfo = Zotero_Storage::getRemoteFileInfo($info); - if (!$remoteInfo) { - error_log("Remote file {$info->hash}/{$info->filename} not found"); - $this->e400("Remote file not found"); - } - if ($remoteInfo->size != $info->size) { - error_log("Uploaded file size does not match " - . "({$remoteInfo->size} != {$info->size}) " - . "for file {$info->hash}/{$info->filename}"); - } - } - - // Set an automatic shared lock in getLocalFileInfo() to prevent - // two simultaneous transactions from adding a file - Zotero_DB::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE"); - Zotero_DB::beginTransaction(); - - if (!isset($storageFileID)) { - // Check if file already exists, which can happen if two identical - // files are uploaded simultaneously - $fileInfo = Zotero_Storage::getLocalFileInfo($info); - if ($fileInfo) { - $storageFileID = $fileInfo['storageFileID']; - } - // If file doesn't exist, add it - else { - $storageFileID = Zotero_Storage::addFile($info); - } - } - Zotero_Storage::updateFileItemInfo($item, $storageFileID, $info); - - Zotero_Storage::logUpload($this->userID, $item, $uploadKey, IPAddress::getIP()); - - Zotero_DB::commit(); - - header("HTTP/1.1 204 No Content"); - header("Last-Modified-Version: " . $item->version); - exit; - } - - - // - // Client post-upload file registration - // - if (isset($_POST['update'])) { - $this->allowMethods(array('POST')); - - if (empty($_POST['mtime'])) { - throw new Exception('File modification time not provided'); - } - - $uploadKey = $_POST['update']; - - $info = Zotero_Storage::getUploadInfo($uploadKey); - if (!$info) { - $this->e400("Upload key not found"); - } - - $remoteInfo = Zotero_Storage::getRemoteFileInfo($info); - if (!$remoteInfo) { - $this->e400("Remote file not found"); - } - if (!isset($info->size)) { - throw new Exception("Size information not available"); - } - - $info->mtime = $_POST['mtime']; - - // Set an automatic shared lock in getLocalFileInfo() to prevent - // two simultaneous transactions from adding a file - Zotero_DB::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE"); - Zotero_DB::beginTransaction(); - - // Check if file already exists, which can happen if two identical - // files are uploaded simultaneously - $fileInfo = Zotero_Storage::getLocalFileInfo($info); - if ($fileInfo) { - $storageFileID = $fileInfo['storageFileID']; - } - else { - $storageFileID = Zotero_Storage::addFile($info); - } - - Zotero_Storage::updateFileItemInfo($item, $storageFileID, $info, true); - - Zotero_Storage::logUpload($this->userID, $item, $uploadKey, IPAddress::getIP()); - - Zotero_DB::commit(); - - header("HTTP/1.1 204 No Content"); - exit; - } - - throw new Exception("Invalid request", Z_ERROR_INVALID_INPUT); - } - exit; - } - - - - /** - * Get a token to pass to the translation server to retain state for multi-item saves - */ - protected function getTranslationToken($obj) { - $allowExplicitToken = $this->queryParams['v'] >= 2 || ($this->queryParams['v'] == 1 && Z_ENV_TESTING_SITE); - - if ($allowExplicitToken && isset($obj->token)) { - if (!isset($obj->items)) { - throw new Exception("'token' is valid only for item selection requests", Z_ERROR_INVALID_INPUT); - } - return $obj->token; - } - - // Bookmarklet uses cookie auth with v1 - if ($this->queryParams['v'] == 1 && $this->cookieAuth) { - return md5($this->userID . $_GET['session']); - } - if (!$allowExplicitToken) { - return false; - } - if (isset($obj->items)) { - throw new Exception("Token not provided with selected items", Z_ERROR_INVALID_INPUT); - } - return md5($this->userID . $_SERVER['REMOTE_ADDR'] . uniqid()); - } -} diff --git a/controllers/KeysController.php b/controllers/KeysController.php deleted file mode 100644 index 2c346e38..00000000 --- a/controllers/KeysController.php +++ /dev/null @@ -1,442 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class KeysController extends ApiController { - public function keys() { - $syncStart = false; - - $userID = $this->objectUserID; - $key = $this->objectName; - if ($key == 'current') { - $key = $this->apiKey; - - if (!empty($_SERVER['HTTP_USER_AGENT']) - && strpos($_SERVER['HTTP_USER_AGENT'], 'Zotero/') !== false) { - $syncStart = true; - } - } - - $this->allowMethods(['GET', 'POST', 'PUT', 'DELETE']); - - if ($key) { - $keyObj = Zotero_Keys::getByKey($key); - if (!$keyObj) { - $this->e404("Key '$key' not found"); - } - $isWebsite = $this->permissions->isSuper() - || ($this->apiVersion >= 3 && $this->cookieAuth && $keyObj->userID == $this->userID); - } - else { - $keyObj = null; - $isWebsite = $this->permissions->isSuper() - || ($this->apiVersion >= 3 && $this->cookieAuth && $userID == $this->userID); - } - - if ($this->method == 'GET') { - // Single key - if ($key) { - // /users//keys/ (deprecated) - if ($userID) { - // If we have a userID, make sure it matches - if ($keyObj->userID != $userID) { - $this->e404("Key not found"); - } - } - // /keys/ - else { - if ($this->apiVersion < 3) { - $this->e404(); - } - } - - if ($this->apiVersion >= 3) { - $json = $keyObj->toJSON(); - - // If not super-user or website user, don't include name or recent IP addresses - if (!$isWebsite) { - unset($json['dateAdded']); - unset($json['lastUsed']); - unset($json['name']); - unset($json['recentIPs']); - } - - header('application/json'); - echo Zotero_Utilities::formatJSON($json); - - if ($syncStart) { - StatsD::increment("sync.start"); - } - } - else { - $this->responseXML = $keyObj->toXML(); - - // If not super-user, don't include name or recent IP addresses - if (!$this->permissions->isSuper()) { - unset($this->responseXML['dateAdded']); - unset($this->responseXML['lastUsed']); - unset($this->responseXML->name); - unset($this->responseXML->recentIPs); - } - } - } - - // All of the user's keys - else { - if (!$isWebsite) { - $this->e403(); - } - - $keyObjs = Zotero_Keys::getUserKeys($userID); - if ($keyObjs) { - if ($this->apiVersion >= 3) { - $json = []; - foreach ($keyObjs as $keyObj) { - $json[] = $keyObj->toJSON(); - } - echo Zotero_Utilities::formatJSON($json); - } - else { - $xml = new SimpleXMLElement(''); - $domXML = dom_import_simplexml($xml); - foreach ($keyObjs as $keyObj) { - $keyXML = $keyObj->toXML(); - $domKeyXML = dom_import_simplexml($keyXML); - $node = $domXML->ownerDocument->importNode($domKeyXML, true); - $domXML->appendChild($node); - } - $this->responseXML = $xml; - } - } - } - } - - else if ($this->method == 'DELETE') { - if (!$key) { - $this->e400("DELETE requests must include a key"); - } - - Zotero_DB::beginTransaction(); - - $keyObj = Zotero_Keys::getByKey($key); - if (!$keyObj) { - $this->e404("Key '$key' does not exist"); - } - $keyObj->erase(); - Zotero_DB::commit(); - - header("HTTP/1.1 204 No Content"); - exit; - } - - else { - if ($this->method == 'POST') { - if ($key) { - $this->e400("POST requests cannot include a key (did you mean PUT?)"); - } - - if ($this->apiVersion >= 3) { - $json = json_decode($this->body, true); - if (!$json) { - $this->e400("$this->method data is not valid JSON"); - } - - if (!empty($json['key'])) { - $this->e400("POST requests cannot contain a key in '" . $this->body . "'"); - } - - // If not website and not /users/:userID/keys, check for 'username'/'password' - // in JSON - if (!$isWebsite) { - if ($userID) { - $this->e403(); - } - $userID = $this->authenticateKeyJSON($json); - } - - $fields = $this->getFieldsFromJSON($json); - } - else { - if (!$isWebsite) { - $this->e403(); - } - - try { - $keyXML = @new SimpleXMLElement($this->body); - } - catch (Exception $e) { - $this->e400("$this->method data is not valid XML"); - } - - if (!empty($key['key'])) { - $this->e400("POST requests cannot contain a key in '" . $this->body . "'"); - } - - $fields = $this->getFieldsFromKeyXML($keyXML); - } - - Zotero_DB::beginTransaction(); - - try { - $keyObj = new Zotero_Key; - $keyObj->userID = $userID; - foreach ($fields as $field=>$val) { - if ($field == 'access') { - foreach ($val as $access) { - $this->setKeyPermissions($keyObj, $access); - } - } - else { - $keyObj->$field = $val; - } - } - $keyObj->save(); - } - catch (Exception $e) { - if ($e->getCode() == Z_ERROR_KEY_NAME_TOO_LONG) { - $this->e400($e->getMessage()); - } - $this->handleException($e); - } - - if ($this->apiVersion >= 3) { - header('application/json'); - echo Zotero_Utilities::formatJSON($keyObj->toJSON()); - } - else { - $this->responseXML = $keyObj->toXML(); - } - - Zotero_DB::commit(); - - $url = Zotero_API::getKeyURI($keyObj); - $this->responseCode = 201; - header("Location: " . $url, false, 201); - } - - else if ($this->method == 'PUT') { - if (!$key) { - $this->e400("PUT requests must include a key (did you mean POST?)"); - } - - if ($this->apiVersion >= 3) { - $json = json_decode($this->body, true); - if (!$json) { - $this->e400("$this->method data is not valid JSON"); - } - - // If not website and not /users/:userID/keys/:key, check for - // 'username'/'password' in JSON - if (!$isWebsite) { - if ($userID) { - $this->e403(); - } - $userID = $this->authenticateKeyJSON($json); - } - - $fields = $this->getFieldsFromJSON($json); - } - else { - if (!$isWebsite) { - $this->e403(); - } - - try { - $keyXML = @new SimpleXMLElement($this->body); - } - catch (Exception $e) { - $this->e400("$this->method data is not valid XML"); - } - - $fields = $this->getFieldsFromKeyXML($keyXML); - } - - // Key attribute is optional, but, if it's there, make sure it matches - if (isset($fields['key']) && $fields['key'] != $key) { - $this->e400("Key '{$fields['key']}' does not match key '$key' from URI"); - } - - Zotero_DB::beginTransaction(); - - try { - $keyObj = Zotero_Keys::getByKey($key); - if (!$keyObj) { - $this->e404("Key '$key' does not exist"); - } - foreach ($fields as $field=>$val) { - if ($field == 'access') { - foreach ($val as $access) { - $this->setKeyPermissions($keyObj, $access); - } - } - else { - $keyObj->$field = $val; - } - } - $keyObj->save(); - } - catch (Exception $e) { - if ($e->getCode() == Z_ERROR_KEY_NAME_TOO_LONG) { - $this->e400($e->getMessage()); - } - $this->handleException($e); - } - - if ($this->apiVersion >= 3) { - echo Zotero_Utilities::formatJSON($keyObj->toJSON()); - } - else { - $this->responseXML = $keyObj->toXML(); - } - - Zotero_DB::commit(); - } - } - - if ($this->apiVersion >= 3) { - $this->end(); - } - else { - header('Content-Type: application/xml'); - $xmlstr = $this->responseXML->asXML(); - - $doc = new DOMDocument('1.0'); - - $doc->loadXML($xmlstr); - $doc->formatOutput = true; - echo $doc->saveXML(); - exit; - } - } - - - private function authenticateKeyJSON($json) { - if (empty($json['username']) || empty($json['password'])) { - $this->e403(); - } - - // Authenticate username/password - $userID = Zotero_Users::authenticate( - 'password', - [ - 'username' => $json['username'], - 'password' => $json['password'] - ] - ); - if (!$userID) { - $this->e403('Invalid username/password'); - } - return $userID; - } - - - protected function getFieldsFromJSON($json) { - if (!isset($json['name'])) { - throw new Exception("Key name not provided", Z_ERROR_INVALID_INPUT); - } - - $fields = []; - $fields['name'] = $json['name']; - $fields['access'] = []; - if (!empty($json['access']['user']) && !empty($json['access']['user']['library'])) { - $fields['access'][] = [ - 'library' => true, - 'notes' => isset($json['access']['user']['notes']) - ? (bool) $json['access']['user']['notes'] - : false, - 'write' => isset($json['access']['user']['write']) - ? (bool) $json['access']['user']['write'] - : false - ]; - } - if (!empty($json['access']['groups'])) { - foreach ($json['access']['groups'] as $groupID => $access) { - $fields['access'][] = [ - 'group' => $groupID == 'all' ? 0 : (int) $groupID, - 'write' => isset($access['write']) ? (bool) $access['write'] : false - ]; - } - } - return $fields; - } - - - protected function getFieldsFromKeyXML(SimpleXMLElement $xml) { - $fields = array(); - $fields['name'] = (string) $xml->name; - $fields['access'] = array(); - foreach ($xml->access as $access) { - $a = array(); - if (isset($access['group'])) { - $a['group'] = $access['group'] == 'all' ? 0 : (int) $access['group']; - } - else { - $a['library'] = (int) $access['library']; - $a['notes'] = (int) $access['notes']; - } - $a['write'] = isset($access['write']) ? (bool) (int) $access['write'] : false; - $fields['access'][] = $a; - } - return $fields; - } - - - protected function setKeyPermissions($keyObj, $accessElement) { - foreach ($accessElement as $accessField=>$accessVal) { - // 'write' is handled below - if ($accessField == 'write') { - continue; - } - - // Group library access () - if ($accessField == 'group') { - // Grant access to all groups - if ($accessVal === 0) { - $keyObj->setPermission(0, 'group', true); - $keyObj->setPermission(0, 'write', $accessElement['write']); - } - else { - $group = Zotero_Groups::get($accessVal); - if (!$group) { - $this->e400("Group not found"); - } - if (!$group->hasUser($keyObj->userID)) { - $this->e400("User $this->id is not a member of group $group->id"); - } - $keyObj->setPermission($group->libraryID, 'library', true); - $keyObj->setPermission($group->libraryID, 'write', $accessElement['write']); - } - } - // Personal library access () - else { - $libraryID = Zotero_Users::getLibraryIDFromUserID($keyObj->userID); - $keyObj->setPermission($libraryID, $accessField, $accessVal); - $keyObj->setPermission($libraryID, 'write', $accessElement['write']); - } - } - } -} diff --git a/controllers/MappingsController.php b/controllers/MappingsController.php deleted file mode 100644 index e12e7393..00000000 --- a/controllers/MappingsController.php +++ /dev/null @@ -1,273 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class MappingsController extends ApiController { - /** - * JSON type/field data - */ - public function mappings() { - if (!empty($_GET['locale']) && $_GET['locale'] != 'en-US') { - $this->e400("Non-English locales are not yet supported"); - } - - $locale = empty($_GET['locale']) ? 'en-US' : $_GET['locale']; - - if ($this->subset == 'itemTypeFields') { - if (empty($_GET['itemType'])) { - $this->e400("'itemType' not provided"); - } - - $itemType = $_GET['itemType']; - - $itemTypeID = Zotero_ItemTypes::getID($itemType); - if (!$itemTypeID) { - $this->e400("Invalid item type '$itemType'"); - } - } - - else if ($this->subset == 'itemTypeCreatorTypes') { - if (empty($_GET['itemType'])) { - $this->e400("'itemType' not provided"); - } - - $itemType = $_GET['itemType']; - - $itemTypeID = Zotero_ItemTypes::getID($itemType); - if (!$itemTypeID) { - $this->e400("Invalid item type '$itemType'"); - } - - // Notes and attachments don't have creators - if ($itemType == 'note' || $itemType == 'attachment') { - echo "[]"; - exit; - } - } - - // TODO: check If-Modified-Since and return 304 if not changed - - $cacheKey = $this->subset . "JSON"; - if (isset($itemTypeID)) { - $cacheKey .= "_" . $itemTypeID; - } - $cacheKey .= '_' . $this->apiVersion; - $ttl = 60; - $json = Z_Core::$MC->get($cacheKey); - if ($json) { - header("Content-Type: application/json"); - echo $json; - exit; - } - - switch ($this->subset) { - case 'itemTypes': - $rows = Zotero_ItemTypes::getAll($locale); - $propName = 'itemType'; - break; - - case 'itemTypeFields': - $fieldIDs = Zotero_ItemFields::getItemTypeFields($itemTypeID); - $rows = array(); - foreach ($fieldIDs as $fieldID) { - $fieldName = Zotero_ItemFields::getName($fieldID); - $rows[] = array( - 'id' => $fieldID, - 'name' => $fieldName, - 'localized' => Zotero_ItemFields::getLocalizedString( - $itemTypeID, $fieldName, $locale - ) - ); - } - $propName = 'field'; - break; - - case 'itemFields': - $rows = Zotero_ItemFields::getAll($locale); - $propName = 'field'; - break; - - case 'itemTypeCreatorTypes': - $rows = Zotero_CreatorTypes::getTypesForItemType($itemTypeID, $locale); - $propName = 'creatorType'; - break; - - case 'creatorFields': - $rows = Zotero_Creators::getLocalizedFieldNames(); - $propName = 'field'; - break; - } - - $json = array(); - foreach ($rows as $row) { - // Before v3, computerProgram's 'versionNumber' was just 'version' - if ($this->apiVersion < 3 - && ($this->subset == 'itemTypeFields' - || $this->subset == 'itemFields') && $row['id'] == 81) { - $row['name'] = 'version'; - } - $json[] = array( - $propName => $row['name'], - 'localized' => $row['localized'] - ); - } - - header("Content-Type: application/json"); - $json = Zotero_Utilities::formatJSON($json); - Z_Core::$MC->set($cacheKey, $json, $ttl); - - echo $json; - exit; - } - - - public function newItem() { - if (empty($_GET['itemType'])) { - $this->e400("'itemType' not provided"); - } - - $itemType = $_GET['itemType']; - if ($itemType == 'attachment') { - if (empty($_GET['linkMode'])) { - $this->e400("linkMode required for itemType=attachment"); - } - - $linkModeName = $_GET['linkMode']; - - try { - $linkMode = Zotero_Attachments::linkModeNameToNumber($linkModeName); - } - catch (Exception $e) { - $this->e400("Invalid linkMode '$linkModeName'"); - } - } - - $itemTypeID = Zotero_ItemTypes::getID($itemType); - if (!$itemTypeID) { - $this->e400("Invalid item type '$itemType'"); - } - - // TODO: check If-Modified-Since and return 304 if not changed - - $cacheVersion = 1; - $cacheKey = "newItemJSON" - . "_" . $this->apiVersion - . "_" . $itemTypeID - . "_" . $cacheVersion; - if ($itemType == 'attachment') { - $cacheKey .= "_" . $linkMode; - } - $cacheKey .= '_' . $this->apiVersion; - $ttl = 60; - $json = Z_Core::$MC->get($cacheKey); - if ($json) { - header("Content-Type: application/json"); - echo $json; - exit; - } - - // Generate template - - $json = array( - 'itemType' => $itemType - ); - if ($itemType == 'attachment') { - $json['linkMode'] = $linkModeName; - } - - $fieldIDs = Zotero_ItemFields::getItemTypeFields($itemTypeID); - $first = true; - foreach ($fieldIDs as $fieldID) { - $fieldName = Zotero_ItemFields::getName($fieldID); - - // Before v3, computerProgram's 'versionNumber' was just 'version' - if ($this->apiVersion < 3 && $fieldID == 81) { - $fieldName = 'version'; - } - - if ($itemType == 'attachment' && $fieldName == 'url' && !preg_match('/_url$/', $linkModeName)) { - continue; - } - - $json[$fieldName] = ""; - - if ($first && $itemType != 'note' && $itemType != 'attachment') { - $creatorTypeID = Zotero_CreatorTypes::getPrimaryIDForType($itemTypeID); - $creatorTypeName = Zotero_CreatorTypes::getName($creatorTypeID); - $json['creators'] = array( - array( - 'creatorType' => $creatorTypeName, - 'firstName' => '', - 'lastName' => '' - ) - ); - $first = false; - } - } - - if ($itemType == 'note' || $itemType == 'attachment') { - $json['note'] = ''; - } - - $json['tags'] = array(); - if ($this->apiVersion >= 2) { - $json['collections'] = array(); - $json['relations'] = new stdClass; - } - - if ($this->apiVersion == 1) { - if ($itemType != 'note' && $itemType != 'attachment') { - $json['attachments'] = array(); - $json['notes'] = array(); - } - } - - if ($itemType == 'attachment') { - $json['contentType'] = ''; - $json['charset'] = ''; - - if ($linkModeName == 'linked_file') { - $json['path'] = ''; - } - - if (preg_match('/^imported_/', $linkModeName)) { - $json['filename'] = ''; - $json['md5'] = null; - $json['mtime'] = null; - //$json['zip'] = false; - } - } - - header("Content-Type: application/json"); - - $json = Zotero_Utilities::formatJSON($json); - Z_Core::$MC->set($cacheKey, $json, $ttl); - - echo $json; - exit; - } -} diff --git a/controllers/SearchesController.php b/controllers/SearchesController.php deleted file mode 100644 index dcab6701..00000000 --- a/controllers/SearchesController.php +++ /dev/null @@ -1,161 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class SearchesController extends ApiController { - public function searches() { - if ($this->apiVersion < 2) { - $this->e404(); - } - - // Check for general library access - if (!$this->permissions->canAccess($this->objectLibraryID)) { - $this->e403(); - } - - if ($this->isWriteMethod()) { - // Check for library write access - if (!$this->permissions->canWrite($this->objectLibraryID)) { - $this->e403("Write access denied"); - } - - // Make sure library hasn't been modified - if (!$this->singleObject) { - $libraryTimestampChecked = $this->checkLibraryIfUnmodifiedSinceVersion(); - } - - Zotero_Libraries::updateVersionAndTimestamp($this->objectLibraryID); - } - - $results = array(); - - // Single search - if ($this->singleObject) { - $this->allowMethods(['HEAD', 'GET', 'PUT', 'PATCH', 'DELETE']); - - $search = Zotero_Searches::getByLibraryAndKey($this->objectLibraryID, $this->objectKey); - - if ($this->isWriteMethod()) { - $search = $this->handleObjectWrite('search', $search ? $search : null); - $this->e204(); - } - - if (!$search) { - $this->e404("Search not found"); - } - - $this->libraryVersion = $search->version; - - if ($this->method == 'HEAD') { - $this->end(); - } - - // Display search - switch ($this->queryParams['format']) { - case 'atom': - $this->responseXML = $search->toAtom($this->queryParams); - break; - - case 'json': - $json = $search->toResponseJSON($this->queryParams, $this->permissions); - echo Zotero_Utilities::formatJSON($json); - break; - - default: - throw new Exception("Unexpected format '" . $this->queryParams['format'] . "'"); - } - } - // Multiple searches - else { - $this->allowMethods(['HEAD', 'GET', 'POST', 'DELETE']); - - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - - // Create a search - if ($this->method == 'POST') { - $this->queryParams['format'] = 'writereport'; - - $obj = $this->jsonDecode($this->body); - $results = Zotero_Searches::updateMultipleFromJSON( - $obj, - $this->queryParams, - $this->objectLibraryID, - $this->userID, - $this->permissions, - $libraryTimestampChecked ? 0 : 1, - null - ); - - if ($cacheKey = $this->getWriteTokenCacheKey()) { - Z_Core::$MC->set($cacheKey, true, $this->writeTokenCacheTime); - } - } - // Delete searches - else if ($this->method == 'DELETE') { - Zotero_DB::beginTransaction(); - foreach ($this->queryParams['searchKey'] as $searchKey) { - Zotero_Searches::delete($this->objectLibraryID, $searchKey); - } - Zotero_DB::commit(); - $this->e204(); - } - // Display searches - else { - $title = "Searches"; - $results = Zotero_Searches::search($this->objectLibraryID, $this->queryParams); - } - - $options = [ - 'action' => $this->action, - 'uri' => $this->uri, - 'results' => $results, - 'requestParams' => $this->queryParams, - 'permissions' => $this->permissions, - 'head' => $this->method == 'HEAD' - ]; - switch ($this->queryParams['format']) { - case 'atom': - $this->responseXML = Zotero_API::multiResponse(array_merge($options, [ - 'title' => $this->getFeedNamePrefix($this->objectLibraryID) . $title - ])); - break; - - case 'json': - case 'keys': - case 'versions': - case 'writereport': - Zotero_API::multiResponse($options); - break; - - default: - throw new Exception("Unexpected format '" . $this->queryParams['format'] . "'"); - } - } - - $this->end(); - } -} diff --git a/controllers/SettingsController.php b/controllers/SettingsController.php deleted file mode 100644 index ae06386f..00000000 --- a/controllers/SettingsController.php +++ /dev/null @@ -1,162 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class SettingsController extends ApiController { - public function settings() { - if ($this->apiVersion < 2) { - $this->e404(); - } - - // Check for general library access - if (!$this->permissions->canAccess($this->objectLibraryID)) { - $this->e403(); - } - - if ($this->isWriteMethod()) { - Zotero_DB::beginTransaction(); - - // Check for library write access - if (!$this->permissions->canWrite($this->objectLibraryID)) { - $this->e403("Write access denied"); - } - - // Make sure library hasn't been modified - if (!$this->singleObject) { - $libraryTimestampChecked = $this->checkLibraryIfUnmodifiedSinceVersion(); - } - - Zotero_Libraries::updateVersionAndTimestamp($this->objectLibraryID); - } - - // Single setting - if ($this->singleObject) { - $this->allowMethods(array('GET', 'PUT', 'DELETE')); - - $setting = Zotero_Settings::getByLibraryAndKey($this->objectLibraryID, $this->objectKey); - if (!$setting) { - if ($this->method == 'PUT') { - $setting = new Zotero_Setting; - $setting->libraryID = $this->objectLibraryID; - $setting->name = $this->objectKey; - } - else { - $this->e404("Setting not found"); - } - } - - if ($this->isWriteMethod()) { - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - - // Update setting - if ($this->method == 'PUT') { - $json = $this->jsonDecode($this->body); - $objectVersionValidated = $this->checkSingleObjectWriteVersion( - 'setting', $setting, $json - ); - - $changed = Zotero_Settings::updateFromJSON( - $setting, - $json, - $this->queryParams, - $this->userID, - $objectVersionValidated ? 0 : 2 - ); - - // If not updated, return the original library version - if (!$changed) { - $this->libraryVersion = Zotero_Libraries::getOriginalVersion( - $this->objectLibraryID - ); - Zotero_DB::rollback(); - $this->e204(); - } - } - // Delete setting - else if ($this->method == 'DELETE') { - Zotero_Settings::delete($this->objectLibraryID, $this->objectKey); - } - else { - throw new Exception("Unexpected method $this->method"); - } - - Zotero_DB::commit(); - $this->e204(); - } - else { - $this->libraryVersion = $setting->version; - $json = $setting->toJSON(true, $this->queryParams); - echo Zotero_Utilities::formatJSON($json); - } - } - // Multiple settings - else { - $this->allowMethods(array('GET', 'POST')); - - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - - // Create a setting - if ($this->method == 'POST') { - $obj = $this->jsonDecode($this->body); - $changed = Zotero_Settings::updateMultipleFromJSON( - $obj, - $this->queryParams, - $this->objectLibraryID, - $this->userID, - $this->permissions, - $libraryTimestampChecked ? 0 : 1, - null - ); - - // If not updated, return the original library version - if (!$changed) { - $this->libraryVersion = Zotero_Libraries::getOriginalVersion( - $this->objectLibraryID - ); - Zotero_DB::rollback(); - } - else { - Zotero_DB::commit(); - } - $this->e204(); - } - // Display all settings - else { - $settings = Zotero_Settings::search($this->objectLibraryID, $this->queryParams); - - $json = new stdClass; - foreach ($settings as $setting) { - $json->{$setting->name} = $setting->toJSON(true, $this->queryParams); - } - - echo Zotero_Utilities::formatJSON($json); - } - } - - $this->end(); - } -} diff --git a/controllers/StorageController.php b/controllers/StorageController.php deleted file mode 100644 index d0e78757..00000000 --- a/controllers/StorageController.php +++ /dev/null @@ -1,168 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class StorageController extends ApiController { - // Use 1TB as a numeric stand-in for unlimited, for now - const UNLIMITED = 1000000; - - // - // Storage-related - // - - public function laststoragesync() { - $this->allowMethods(array('GET', 'POST')); - - // Uninitialized publications library - if (!$this->objectLibraryID) { - $this->e404(); - } - - // Deprecated after 3.0, which used auth=1 - if ($this->apiVersion < 2 || !empty($_GET['auth'])) { - $lastSync = Zotero_Users::getLastStorageSync($this->objectUserID); - } - else { - $lastSync = Zotero_Libraries::getLastStorageSync($this->objectLibraryID); - } - if (!$lastSync) { - $this->e404(); - } - - echo $lastSync; - exit; - } - - - public function removestoragefiles() { - $this->allowMethods(array('POST')); - $sql = "DELETE SFI FROM storageFileItems SFI JOIN items USING (itemID) WHERE libraryID=?"; - Zotero_DB::query($sql, $this->objectLibraryID, Zotero_Shards::getByLibraryID($this->objectLibraryID)); - Zotero_Storage::clearUserUsage(Zotero_Libraries::getOwner($this->objectLibraryID)); - - $sql = "DELETE FROM storageFileLibraries WHERE libraryID = ?"; - Zotero_DB::query($sql, $this->objectLibraryID); - header("HTTP/1.1 204 No Content"); - exit; - } - - - public function storageadmin() { - if (!$this->permissions->isSuper()) { - $this->e404(); - } - - $this->allowMethods(array('GET', 'POST')); - - Zotero_DB::beginTransaction(); - - if ($this->method == 'POST') { - if (!isset($_POST['quota'])) { - $this->e400("Quota not provided"); - } - // Accept 'unlimited' via API - if ($_POST['quota'] == 'unlimited') { - $_POST['quota'] = self::UNLIMITED; - } - if (!isset($_POST['expiration'])) { - $this->e400("Expiration not provided"); - } - if (!is_numeric($_POST['quota']) || $_POST['quota'] < 0) { - $this->e400("Invalid quota"); - } - if (!is_numeric($_POST['expiration'])) { - $this->e400("Invalid expiration"); - } - $halfHourAgo = strtotime("-30 minutes"); - if ($_POST['expiration'] != 0 && $_POST['expiration'] < $halfHourAgo) { - $this->e400("Expiration is in the past"); - } - - try { - Zotero_Storage::setUserValues($this->objectUserID, $_POST['quota'], $_POST['expiration']); - } - catch (Exception $e) { - if ($e->getCode() == Z_ERROR_GROUP_QUOTA_SET_BELOW_USAGE) { - $this->e409("Cannot set quota below current usage"); - } - $this->handleException($e); - } - } - - // GET request - $xml = new SimpleXMLElement(''); - $quota = Zotero_Storage::getEffectiveUserQuota($this->objectUserID); - $xml->quota = $quota; - $instQuota = Zotero_Storage::getInstitutionalUserQuota($this->objectUserID); - // If personal quota is in effect - if (!$instQuota || $quota > $instQuota) { - $values = Zotero_Storage::getUserValues($this->objectUserID); - if ($values) { - $xml->expiration = (int) $values['expiration']; - } - } - // Return 'unlimited' via API - if ($quota == self::UNLIMITED) { - $xml->quota = 'unlimited'; - } - $usage = Zotero_Storage::getUserUsage($this->objectUserID); - $xml->usage->total = $usage['total']; - $xml->usage->library = $usage['library']; - - foreach ($usage['groups'] as $group) { - if (!isset($group['id'])) { - throw new Exception("Group id isn't set"); - } - if (!isset($group['usage'])) { - throw new Exception("Group usage isn't set"); - } - $xmlGroup = $xml->usage->addChild('group', $group['usage']); - $xmlGroup['id'] = $group['id']; - } - - Zotero_DB::commit(); - - header('application/xml'); - echo $xml->asXML(); - exit; - } - - - public function storagetransferbucket() { - // DISABLED - $this->e404(); - - if (!$this->permissions->isSuper()) { - $this->e404(); - } - - $this->allowMethods(array('POST')); - - Zotero_Storage::transferBucket('zoterofilestorage', 'zoterofilestoragetest'); - exit; - } -} diff --git a/controllers/SyncController.php b/controllers/SyncController.php deleted file mode 100644 index 04490d04..00000000 --- a/controllers/SyncController.php +++ /dev/null @@ -1,1128 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class SyncController extends Controller { - private $validAPIVersions = array(9); - private $sessionLifetime = 3600; - - private $profile = false; - private $profileShard = 0; - - private $apiVersion; - private $sessionID = null; - private $userID = null; - private $userLibraryID = null; - private $ipAddress = null; - private $updateKey = null; - private $responseXML = null; - - private $startTime = false; - private $timeLogged = false; - - public function __get($field) { - switch ($field) { - case 'apiVersion': - case 'userID': - case 'userLibraryID': - case 'updateKey': - return $this->$field; - - default: - trigger_error("Invalid field '$field'", E_USER_ERROR); - } - } - - public function init($extra) { - require_once('../model/Error.inc.php'); - - if ($this->profile) { - Zotero_DB::profileStart($this->profileShard); - } - $this->startTime = microtime(true); - - // Inflate gzipped data - if (!empty($_SERVER['HTTP_CONTENT_ENCODING']) && $_SERVER['HTTP_CONTENT_ENCODING'] == 'gzip') { - $gzdata = file_get_contents('php://input'); - - // Firefox 12 and above include the standard gzip header, - // which needs to be stripped - if (substr($gzdata, 0, 3) == (chr(31) . chr(139) . chr(8))) { // 1F 8B 08 - $gzdata = substr($gzdata, 10); - } - - $data = gzinflate($gzdata); - parse_str($data, $_POST); - foreach ($_POST as $key=>$val) { - $_REQUEST[$key] = $val; - } - } - - $this->responseXML = Zotero_Sync::getResponseXML(); - - //if (!Z_CONFIG::$SYNC_ENABLED && $_SERVER["REMOTE_ADDR"] != '') { - if (!Z_CONFIG::$SYNC_ENABLED) { - $this->error(503, 'SERVER_ERROR', Z_CONFIG::$MAINTENANCE_MESSAGE); - } - - if (empty($_REQUEST['version'])) { - if ($this->action == 'index') { - echo "Nothing to see here."; - exit; - } - - $this->error(400, 'NO_API_VERSION', "API version not specified"); - } - - $upgradeMessage = "Due to improvements made to sync functionality, you must upgrade to Zotero 3.0 or later from zotero.org to sync your Zotero library."; - - if (isset($_SERVER['HTTP_X_ZOTERO_VERSION'])) { - require_once('../model/ToolkitVersionComparator.inc.php'); - - // Avoid infinite loop due to client bug - if ($_SERVER['HTTP_X_ZOTERO_VERSION'] == "2.0b6") { - die ("Please upgrade to the latest version of Zotero from zotero.org."); - } - else if (ToolkitVersionComparator::compare($_SERVER['HTTP_X_ZOTERO_VERSION'], "3.0") < 0) { - $this->error(400, 'UPGRADE_REQUIRED', $upgradeMessage); - } - else if (isset($_SERVER['HTTP_USER_AGENT']) - && (strpos($_SERVER['HTTP_USER_AGENT'], "Firefox/17") !== false - || strpos($_SERVER['HTTP_USER_AGENT'], "Firefox/18") !== false - || strpos($_SERVER['HTTP_USER_AGENT'], "Firefox/19") !== false - || strpos($_SERVER['HTTP_USER_AGENT'], "Firefox/20") !== false - || strpos($_SERVER['HTTP_USER_AGENT'], "Firefox/21") !== false - || strpos($_SERVER['HTTP_USER_AGENT'], "Firefox/22") !== false) - && ToolkitVersionComparator::compare($_SERVER['HTTP_X_ZOTERO_VERSION'], "3.0.9") < 0) { - $this->error(400, 'UPGRADE_REQUIRED', "Your version of Zotero is not compatible with Firefox 17 or later. Please upgrade to the latest version of Zotero from zotero.org."); - } - } - - if (!in_array($_REQUEST['version'], $this->validAPIVersions)) { - if ($_REQUEST['version'] < 9) { - $this->error(400, 'UPGRADE_REQUIRED', $upgradeMessage); - } - $this->error(400, 'INVALID_API_VERSION', "Invalid request API version '{$_REQUEST['version']}'"); - } - - $this->apiVersion = (int) $_REQUEST['version']; - $this->responseXML['version'] = $this->apiVersion; - } - - - public function login() { - // TODO: Change to POST only - - if (empty($_REQUEST['username'])) { - $this->error(403, 'NO_USER_NAME', "Username not provided"); - } - else if (empty($_REQUEST['password'])) { - $this->error(403, 'NO_PASSWORD', "Password not provided"); - } - - $username = $_REQUEST['username']; - $password = $_REQUEST['password']; - - $authData = array('username' => $username, 'password' => $password); - - $userID = Zotero_Users::authenticate('password', $authData); - if (!$userID) { - StatsD::increment("sync.login.failure"); - if (isset($_SERVER['HTTP_X_ZOTERO_VERSION']) && $_SERVER['HTTP_X_ZOTERO_VERSION'] == "2.0b6") { - die ("Username/password not accepted"); - } - $this->error(403, 'INVALID_LOGIN', "Username/password not accepted"); - } - - StatsD::increment("sync.login.success"); - - $sessionID = md5($userID . uniqid(rand(), true) . $password); - $ip = IPAddress::getIP(); - - $sql = "INSERT INTO sessions (sessionID, userID, ipAddress) - VALUES (?,?,INET_ATON(?))"; - Zotero_DB::query($sql, array($sessionID, $userID, $ip)); - - Z_Core::$MC->set( - "syncSession_$sessionID", - array( - 'sessionID' => $sessionID, - 'userID' => $userID - ), - // See note in sessionCheck() - $this->sessionLifetime - 600 - ); - - $this->responseXML->sessionID = $sessionID; - $this->end(); - } - - - public function logout() { - Zotero_DB::beginTransaction(); - - $this->sessionCheck(); - - $sql = "DELETE FROM sessions WHERE sessionID=?"; - Zotero_DB::query($sql, $this->sessionID); - - Z_Core::$MC->delete("syncSession_" . $this->sessionID); - - Zotero_DB::commit(); - - $this->responseXML->addChild('loggedout'); - $this->end(); - } - - - /** - * Now a noop -- to be removed - */ - public function unlock() { - $this->responseXML->addChild('unlocked'); - $this->end(); - } - - - public function index() { - $this->end(); - } - - - public function updated() { - // Shards can use read-only mode - Zotero_DB::readOnly(true); - // Master remains writable for session/queue data - Zotero_DB::readOnly(false, 0); - - if (empty($_REQUEST['lastsync'])) { - $this->error(400, 'NO_LAST_SYNC_TIME', 'Last sync time not provided'); - } - - $lastsync = false; - if (is_numeric($_REQUEST['lastsync'])) { - $lastsync = (int) $_REQUEST['lastsync']; - } - else { - $this->error(400, 'INVALID_LAST_SYNC_TIME', 'Last sync time is invalid'); - } - - $this->sessionCheck(); - - if (!Z_ENV_DEV_SITE && !Z_Core::$MC->get('z4_eol_' . $this->userID)) { - $date = date('Y-m-d'); - if ($date >= '2018-07-17') { - $ttl = 43200; // 12 hours - } - else if ($date >= '2018-07-20') { - $ttl = 7200; // 6 hours - } - else if ($date >= '2018-07-25') { - $ttl = 3600; // 1 hour - } - else if ($date >= '2018-07-27') { - $ttl = 300; // 5 minutes - } - else if ($date >= '2018-08-01') { - $ttl = 60; // 1 minute - } - else if ($date >= '2018-08-02') { - $upgradeMessage = "Zotero 4 syncing is no longer supported. Please upgrade to Zotero 5 to continue syncing."; - $this->error(400, 'UPGRADE_REQUIRED', $upgradeMessage); - } - else { - $ttl = 86400; // 1 day - } - Z_Core::$MC->set('z4_eol_' . $this->userID, 1, $ttl); - $upgradeMessage = "Zotero 4 will no longer sync after August 1, 2018. Please upgrade to Zotero 5 to continue syncing."; - $this->error(500, 'UPGRADE_REQUIRED', $upgradeMessage); - } - - if (isset($_SERVER['HTTP_X_ZOTERO_VERSION'])) { - require_once('../model/ToolkitVersionComparator.inc.php'); - - if (ToolkitVersionComparator::compare($_SERVER['HTTP_X_ZOTERO_VERSION'], "2.0.4") < 0) { - $futureUsers = Z_Core::$MC->get('futureUsers'); - if (!$futureUsers) { - $futureUsers = Zotero_DB::columnQuery("SELECT userID FROM futureUsers"); - Z_Core::$MC->set('futureUsers', $futureUsers, 1800); - } - - if (in_array($this->userID, $futureUsers)) { - Z_Core::logError("Blocking sync for future user " . $this->userID . " with version " . $_SERVER['HTTP_X_ZOTERO_VERSION']); - $upgradeMessage = "Due to improvements made to sync functionality, you must upgrade to Zotero 2.0.6 or later (via Firefox's Tools menu -> Add-ons -> Extensions -> Find Updates or from zotero.org) to continue syncing your Zotero library."; - $this->error(400, 'UPGRADE_REQUIRED', $upgradeMessage); - } - } - } - - $doc = new DOMDocument(); - $domResponse = dom_import_simplexml($this->responseXML); - $domResponse = $doc->importNode($domResponse, true); - $doc->appendChild($domResponse); - - try { - $result = Zotero_Sync::getSessionDownloadResult($this->sessionID); - } - catch (Exception $e) { - $this->handleUpdatedError($e); - } - - // XML response - if (is_string($result)) { - $this->clearWaitTime($this->sessionID); - $this->responseXML = new SimpleXMLElement($result); - $this->end(); - } - - // Queued - if ($result === false) { - $queued = $this->responseXML->addChild('locked'); - $queued['wait'] = $this->getWaitTime($this->sessionID); - $this->end(); - } - - // Not queued - if ($result == -1) { - // See if we're locked - Zotero_DB::beginTransaction(); - if (Zotero_Sync::userIsWriteLocked($this->userID) - // If client knows it will be uploading, check for read lock as well - || (!empty($_REQUEST['upload']) && Zotero_Sync::userIsReadLocked($this->userID))) { - Zotero_DB::commit(); - $locked = $this->responseXML->addChild('locked'); - $locked['wait'] = $this->getWaitTime($this->sessionID); - $this->end(); - } - Zotero_DB::commit(); - - $queue = true; - if (Z_ENV_TESTING_SITE && !empty($_GET['noqueue'])) { - $queue = false; - } - - // TEMP - $cacheKeyExtra = (!empty($_POST['ft']) ? json_encode($_POST['ft']) : "") - . (!empty($_POST['ftkeys']) ? json_encode($_POST['ftkeys']) : ""); - - // If we have a cached response, return that - try { - $startedTimestamp = microtime(true); - $cached = Zotero_Sync::getCachedDownload($this->userID, $lastsync, $this->apiVersion, $cacheKeyExtra); - - // Not locked, so clear wait index - $this->clearWaitTime($this->sessionID); - - if ($cached) { - $this->responseXML = simplexml_load_string($cached, "SimpleXMLElement", LIBXML_COMPACT | LIBXML_PARSEHUGE); - // TEMP - if (!$this->responseXML) { - error_log("Invalid cached XML data -- stripping control characters"); - // Strip control characters in XML data - $cached = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $cached); - $this->responseXML = simplexml_load_string($cached, "SimpleXMLElement", LIBXML_COMPACT | LIBXML_PARSEHUGE); - } - - $duration = round((float) microtime(true) - $startedTimestamp, 2); - Zotero_Sync::logDownload( - $this->userID, - round($lastsync), - strlen($cached), - $this->ipAddress ? $this->ipAddress : 0, - 0, - $duration, - $duration, - (int) !$this->responseXML - ); - - StatsD::increment("sync.process.download.cache.hit"); - - if (!$this->responseXML) { - $msg = "Error parsing cached XML for user " . $this->userID; - error_log($msg); - $this->handleUpdatedError(new Exception($msg)); - } - - $this->end(); - } - } - catch (Exception $e) { - $msg = $e->getMessage(); - if (strpos($msg, "Too many connections") !== false) { - $msg = "'Too many connections' from MySQL"; - } - else { - $msg = "'$msg'"; - } - Z_Core::logError("Warning: $msg getting cached download"); - StatsD::increment("sync.process.download.cache.error"); - } - - try { - $num = Zotero_Items::countUpdated($this->userID, $lastsync, 5); - } - catch (Exception $e) { - // We can get a MySQL lock timeout here if the upload starts - // after the write lock check above but before we get here - $this->handleUpdatedError($e); - } - - // If nothing updated, or if just a few objects and processing is enabled, process synchronously - if ($num == 0 || ($num < 5 && Z_CONFIG::$PROCESSORS_ENABLED)) { - $queue = false; - } - - $params = []; - if (isset($_POST['ft'])) $params['ft'] = $_POST['ft']; - if (isset($_POST['ftkeys'])) { - $queue = true; - $params['ftkeys'] = $_POST['ftkeys']; - } - - if ($queue) { - Zotero_Sync::queueDownload($this->userID, $this->sessionID, $lastsync, $this->apiVersion, $num, $params); - - try { - Zotero_Processors::notifyProcessors('download'); - } - catch (Exception $e) { - Z_Core::logError($e); - } - - $locked = $this->responseXML->addChild('locked'); - $locked['wait'] = 1000; - } - else { - try { - Zotero_Sync::processDownload($this->userID, $lastsync, $doc, $params); - $this->responseXML = simplexml_import_dom($doc); - - StatsD::increment("sync.process.download.immediate.success"); - } - catch (Exception $e) { - StatsD::increment("sync.process.download.immediate.error"); - - $this->handleUpdatedError($e); - } - } - - $this->end(); - } - - throw new Exception("Unexpected session result $result"); - } - - - public function noop() {} - - - /** - * Handle uploaded data, overwriting existing data - */ - public function upload() { - $this->sessionCheck(); - - // Another session is either queued or writing — upload data won't be valid, - // so client should wait and return to /updated with 'upload' flag - Zotero_DB::beginTransaction(); - if (Zotero_Sync::userIsReadLocked($this->userID) || Zotero_Sync::userIsWriteLocked($this->userID)) { - Zotero_DB::commit(); - $locked = $this->responseXML->addChild('locked'); - $locked['wait'] = $this->getWaitTime($this->sessionID); - $this->end(); - } - Zotero_DB::commit(); - - $this->clearWaitTime($this->sessionID); - - if (empty($_REQUEST['updateKey'])) { - $this->error(400, 'INVALID_UPLOAD_DATA', 'Update key not provided'); - } - - if ($_REQUEST['updateKey'] != Zotero_Users::getUpdateKey($this->userID)) { - $this->e409("Server data has changed since last retrieval"); - } - - // TODO: change to POST - if (empty($_REQUEST['data'])) { - $this->error(400, 'MISSING_UPLOAD_DATA', 'Uploaded data not provided'); - } - - $xmldata =& $_REQUEST['data']; - - try { - $doc = new DOMDocument(); - $doc->loadXML($xmldata, LIBXML_PARSEHUGE); - - // For huge uploads, make sure notes aren't bigger than SimpleXML can parse - if (strlen($xmldata) > 7000000) { - $xpath = new DOMXPath($doc); - $results = $xpath->query('/data/items/item/note[string-length(text()) > ' . Zotero_Notes::$MAX_NOTE_LENGTH . ']'); - if ($results->length) { - $noteElem = $results->item(0); - $text = $noteElem->textContent; - $libraryID = $noteElem->parentNode->getAttribute('libraryID'); - $key = $noteElem->parentNode->getAttribute('key'); - - // 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 ($key) { - $msg .= " for item '" . $libraryID . "/" . $key . "'"; - } - throw new Exception($msg, Z_ERROR_NOTE_TOO_LONG); - } - } - } - catch (Exception $e) { - $this->handleUploadError($e, $xmldata); - } - - function relaxNGErrorHandler($errno, $errstr) { - //Z_Core::logError($errstr); - } - set_error_handler('relaxNGErrorHandler'); - set_time_limit(60); - if (!$doc->relaxNGValidate(Z_ENV_MODEL_PATH . 'relax-ng/upload.rng')) { - $id = substr(md5(uniqid(rand(), true)), 0, 10); - $str = date("D M j G:i:s T Y") . "\n"; - $str .= "IP address: " . $_SERVER['REMOTE_ADDR'] . "\n"; - if (isset($_SERVER['HTTP_X_ZOTERO_VERSION'])) { - $str .= "Version: " . $_SERVER['HTTP_X_ZOTERO_VERSION'] . "\n"; - } - $str .= "Error: RELAX NG validation failed\n\n"; - $str .= $xmldata; - if (!file_put_contents(Z_CONFIG::$SYNC_ERROR_PATH . $id, $str)) { - error_log("Unable to save error report to " . Z_CONFIG::$SYNC_ERROR_PATH . $id); - } - $this->error(500, 'INVALID_UPLOAD_DATA', "Uploaded data not well-formed (Report ID: $id)"); - } - restore_error_handler(); - - try { - $xml = simplexml_import_dom($doc); - - $queue = true; - if (Z_ENV_TESTING_SITE && !empty($_GET['noqueue'])) { - $queue = false; - } - if ($queue) { - $affectedLibraries = Zotero_Sync::parseAffectedLibraries($xmldata); - // Relations-only uploads don't have affected libraries - if (!$affectedLibraries) { - $affectedLibraries = array(Zotero_Users::getLibraryIDFromUserID($this->userID)); - } - Zotero_Sync::queueUpload($this->userID, $this->sessionID, $xmldata, $affectedLibraries); - - try { - Zotero_Processors::notifyProcessors('upload'); - Zotero_Processors::notifyProcessors('error'); - usleep(750000); - } - catch (Exception $e) { - Z_Core::logError($e); - } - - // Give processor a chance to finish while we're still here - $this->uploadstatus(); - } - else { - set_time_limit(210); - $timestamp = Zotero_Sync::processUpload($this->userID, $xml); - - $this->responseXML['timestamp'] = $timestamp; - $this->responseXML->addChild('uploaded'); - - $this->end(); - } - } - catch (Exception $e) { - $this->handleUploadError($e, $xmldata); - } - } - - - public function uploadstatus() { - $this->sessionCheck(); - - $result = Zotero_Sync::getSessionUploadResult($this->sessionID); - - if ($result === false) { - $queued = $this->responseXML->addChild('queued'); - $queued['wait'] = $this->getWaitTime($this->sessionID); - $this->end(); - } - $this->clearWaitTime($this->sessionID); - - if (!isset($result['exception'])) { - $this->responseXML['timestamp'] = $result['timestamp']; - $this->responseXML->addChild('uploaded'); - $this->end(); - } - - if (is_array($result)) { - $this->handleUploadError($result['exception'], $result['xmldata']); - } - - throw new Exception("Unexpected session result $result"); - } - - - public function items() { - $this->sessionCheck(); - $this->exitClean(); - } - - - - public function clear() { - $this->sessionCheck(); - - if (Zotero_Sync::userIsReadLocked($this->userID) || - Zotero_Sync::userIsWriteLocked($this->userID)) { - $message = "You cannot reset server data while one of your libraries " - . "is locked for syncing. Please wait for all related syncs to complete."; - $this->error(400, 'SYNC_LOCKED', $message); - } - - StatsD::increment("sync.clear"); - - Zotero_Users::clearAllData($this->userID); - $this->responseXML->addChild('cleared'); - $this->end(); - } - - - // - // Private methods - // - - /** - * Make sure we have a valid session - */ - private function sessionCheck() { - if (empty($_REQUEST['sessionid'])) { - $this->error(403, 'NO_SESSION_ID', "Session ID not provided"); - } - - if (!preg_match('/^[a-f0-9]{32}$/', $_REQUEST['sessionid'])) { - $this->error($this->apiVersion >= 9 ? 403 : 500, 'INVALID_SESSION_ID', "Invalid session ID"); - } - - $sessionID = $_REQUEST['sessionid']; - - $session = Z_Core::$MC->get("syncSession_$sessionID"); - $userID = $session ? $session['userID'] : null; - // TEMP: can switch to just $session - $ipAddress = isset($session['ipAddress']) ? $session['ipAddress'] : null; - if (!$userID) { - $sql = "SELECT userid, (UNIX_TIMESTAMP(NOW())-UNIX_TIMESTAMP(timestamp)) AS age, - INET_NTOA(ipAddress) AS ipAddress FROM sessions WHERE sessionID=?"; - $session = Zotero_DB::rowQuery($sql, $sessionID); - - if (!$session) { - $this->error($this->apiVersion >= 9 ? 403 : 500, 'INVALID_SESSION_ID', "Invalid session ID"); - } - - if ($session['age'] > $this->sessionLifetime) { - $this->error($this->apiVersion >= 9 ? 403 : 500, 'SESSION_TIMED_OUT', "Session timed out"); - } - - $userID = $session['userid']; - $ipAddress = $session['ipAddress']; - } - - $updated = Z_Core::$MC->set( - "syncSession_$sessionID", - array( - 'sessionID' => $sessionID, - 'userID' => $userID, - 'ipAddress' => $ipAddress - ), - // Store in memcached for 10 minutes less than session timeout, - // since we update the DB at a minimum of every 20 minutes - // and a memory-only session could cause FK errors - $this->sessionLifetime - 1200 - ); - - // Every 20 minutes, update the timestamp in the DB - if (!Z_Core::$MC->get("syncSession_" . $sessionID . "_dbUpdated")) { - $sql = "UPDATE sessions SET timestamp=NOW() WHERE sessionID=?"; - Zotero_DB::query($sql, $sessionID); - - Z_Core::$MC->set("syncSession_" . $sessionID . "_dbUpdated", true, 1200); - } - - $this->sessionID = $sessionID; - $this->userID = $userID; - $this->userLibraryID = Zotero_Users::getLibraryIDFromUserID($userID); - $this->ipAddress = $ipAddress; - } - - - private function getWaitTime($sessionID) { - $cacheKey = 'syncWaitIndex_' . $sessionID; - $index = Z_Core::$MC->get($cacheKey); - if ($index === false) { - Z_Core::$MC->add($cacheKey, 1); - $index = 0; - } - - if ($index == 0) { - $wait = 2; - } - else if ($index < 5) { - $wait = 5; - } - else if ($index < 9) { - $wait = 25; - } - else if ($index < 13) { - $wait = 45; - } - else if ($index < 23) { - $wait = 70; - } - else { - $wait = 130; - } - - Z_Core::$MC->increment($cacheKey); - return $wait * 1000; - } - - - private function clearWaitTime($sessionID) { - $index = Z_Core::$MC->delete('syncWaitIndex_' . $sessionID); - } - - - private function throttle($seconds=300) { - $throttle = $this->responseXML->addChild('throttle'); - $throttle['delay'] = $seconds; - $this->end(); - } - - - private function handleUpdatedError(Exception $e) { - if ($this->responseXML) { - unset($this->responseXML->updated); - } - else { - $this->responseXML = Zotero_Sync::getResponseXML($this->apiVersion); - } - - $msg = $e->getMessage(); - - //if (strpos($msg, "Can't connect to MySQL server on") !== false) { - // $this->error(503, 'SERVER_ERROR', "Syncing is currently unavailable for some users due to a server issue. We're working to restore service as soon as possible. Our apologies for the inconvenience."); - //} - - if (strpos($msg, "Lock wait timeout exceeded; try restarting transaction") !== false - || strpos($msg, "Deadlock found when trying to get lock; try restarting transaction") !== false - || strpos($msg, "Too many connections") !== false - || strpos($msg, "Can't connect to MySQL server") !==false - || strpos($msg, " is down") !==false - || $e->getCode() == Z_ERROR_SHARD_UNAVAILABLE) { - $waitTime = $this->getWaitTime($this->sessionID); - Z_Core::logError("WARNING: $msg -- sending sync wait ($waitTime)"); - $locked = $this->responseXML->addChild('locked'); - $locked['wait'] = $waitTime; - $this->end(); - } - - if (Z_ENV_TESTING_SITE) { - throw ($e); - } - else { - $id = substr(md5(uniqid(rand(), true)), 0, 10); - $str = date("D M j G:i:s T Y") . "\n"; - $str .= "IP address: " . $_SERVER['REMOTE_ADDR'] . "\n"; - if (isset($_SERVER['HTTP_X_ZOTERO_VERSION'])) { - $str .= "Version: " . $_SERVER['HTTP_X_ZOTERO_VERSION'] . "\n"; - } - $str .= "Error: " . $e; - $str .= $this->responseXML->saveXML(); - if (!file_put_contents(Z_CONFIG::$SYNC_ERROR_PATH . $id, $str)) { - error_log("Unable to save error report to " . Z_CONFIG::$SYNC_ERROR_PATH . $id); - } - - $this->error(500, 'INVALID_OUTPUT', "Invalid response from server (Report ID: $id)"); - } - } - - - private function handleUploadError(Exception $e, $xmldata) { - $msg = $e->getMessage(); - if ($msg[0] == '=') { - $msg = substr($msg, 1); - $explicit = true; - - // TODO: more specific error messages - } - else { - $explicit = false; - } - - switch ($e->getCode()) { - case Z_ERROR_TAG_TOO_LONG: - case Z_ERROR_COLLECTION_TOO_LONG: - break; - - default: - Z_Core::logError($msg); - } - - if (!$explicit && Z_ENV_TESTING_SITE) { - switch ($e->getCode()) { - case Z_ERROR_COLLECTION_NOT_FOUND: - case Z_ERROR_CREATOR_NOT_FOUND: - case Z_ERROR_ITEM_NOT_FOUND: - case Z_ERROR_COLLECTION_TOO_LONG: - case Z_ERROR_TAG_TOO_LONG: - case Z_ERROR_LIBRARY_ACCESS_DENIED: - case Z_ERROR_TAG_LINKED_ITEM_NOT_FOUND: - break; - - default: - throw ($e); - } - - $id = 'N/A'; - } - else { - $id = substr(md5(uniqid(rand(), true)), 0, 8); - $str = date("D M j G:i:s T Y") . "\n"; - $str .= "IP address: " . $_SERVER['REMOTE_ADDR'] . "\n"; - if (isset($_SERVER['HTTP_X_ZOTERO_VERSION'])) { - $str .= "Version: " . $_SERVER['HTTP_X_ZOTERO_VERSION'] . "\n"; - } - $str .= $msg; - switch ($e->getCode()) { - // Don't log uploaded data for some errors - case Z_ERROR_TAG_TOO_LONG: - case Z_ERROR_FIELD_TOO_LONG: - case Z_ERROR_NOTE_TOO_LONG: - case Z_ERROR_COLLECTION_TOO_LONG: - break; - - default: - $str .= "\n\n" . $xmldata; - } - if (!file_put_contents(Z_CONFIG::$SYNC_ERROR_PATH . $id, $str)) { - error_log("Unable to save error report to " . Z_CONFIG::$SYNC_ERROR_PATH . $id); - } - } - - Zotero_DB::rollback(true); - - switch ($e->getCode()) { - case Z_ERROR_LIBRARY_ACCESS_DENIED: - preg_match('/[Ll]ibrary ([0-9]+)/', $e->getMessage(), $matches); - $libraryID = $matches ? $matches[1] : null; - - $this->error(400, 'LIBRARY_ACCESS_DENIED', - "Cannot make changes to library (Report ID: $id)", - array('libraryID' => $libraryID) - ); - break; - - case Z_ERROR_ITEM_NOT_FOUND: - case Z_ERROR_COLLECTION_NOT_FOUND: - case Z_ERROR_CREATOR_NOT_FOUND: - error_log($e->getMessage()); - $this->error(500, "FULL_SYNC_REQUIRED", - "Please perform a full sync in the Sync->Reset pane of the Zotero preferences. (Report ID: $id)" - ); - break; - - case Z_ERROR_TAG_TOO_LONG: - $message = $e->getMessage(); - preg_match("/Tag '(.+)' too long/s", $message, $matches); - if ($matches) { - $name = $matches[1]; - $this->error(400, "TAG_TOO_LONG", - "Tag '" . mb_substr($name, 0, 50) . "…' too long", - array(), - array("tag" => $name) - ); - } - break; - - case Z_ERROR_COLLECTION_TOO_LONG: - $message = $e->getMessage(); - preg_match("/Collection '(.+)' too long/s", $message, $matches); - if ($matches) { - $name = $matches[1]; - $this->error(400, "COLLECTION_TOO_LONG", - "Collection '" . mb_substr($name, 0, 50) . "…' too long" - ); - } - break; - - case Z_ERROR_NOTE_TOO_LONG: - preg_match("/Note '(.+)' too long(?: for item '(.+)\/(.+)')?/s", $msg, $matches); - if ($matches) { - $name = $matches[1]; - $libraryID = false; - if (isset($matches[2])) { - $libraryID = (int) $matches[2]; - $itemKey = $matches[3]; - if (Zotero_Libraries::getType($libraryID) == 'group') { - $groupID = Zotero_Groups::getGroupIDFromLibraryID($libraryID); - $group = Zotero_Groups::get($groupID); - $libraryName = $group->name; - } - else { - $libraryName = false; - } - } - else { - $itemKey = ''; - } - $showNoteKey = false; - if (isset($_SERVER['HTTP_X_ZOTERO_VERSION'])) { - require_once('../model/ToolkitVersionComparator.inc.php'); - $showNoteKey = ToolkitVersionComparator::compare($_SERVER['HTTP_X_ZOTERO_VERSION'], "4.0.27") < 0; - } - if ($showNoteKey) { - $this->error(400, "ERROR_PROCESSING_UPLOAD_DATA", - "The note '" . mb_substr($name, 0, 50) . "…' in " - . ($libraryName === false - ? "your library " - : "the group '$libraryName' ") - . "is too long to sync to zotero.org.\n\n" - . "Search for the excerpt above or copy and paste " - . "'$itemKey' into the Zotero search bar. " - . "Shorten the note, or delete it and empty the Zotero " - . "trash, and then try syncing again." - ); - } - else { - $this->error(400, "NOTE_TOO_LONG", - "The note '" . mb_substr($name, 0, 50) . "…' in " - . ($libraryName === false - ? "your library " - : "the group '$libraryName' ") - . "is too long to sync to zotero.org.\n\n" - . "Shorten the note, or delete it and empty the Zotero " - . "trash, and then try syncing again.", - [], - $libraryID ? ["item" => $libraryID . "/" . $itemKey] : [] - ); - } - } - break; - - case Z_ERROR_FIELD_TOO_LONG: - preg_match("/(.+) field value '(.+)\.\.\.' too long(?: for item '(.+)')?/s", $msg, $matches); - if ($matches) { - $fieldName = $matches[1]; - $value = $matches[2]; - if (isset($matches[3])) { - $parts = explode("/", $matches[3]); - $libraryID = (int) $parts[0]; - $itemKey = $parts[1]; - if (Zotero_Libraries::getType($libraryID) == 'group') { - $groupID = Zotero_Groups::getGroupIDFromLibraryID($libraryID); - $group = Zotero_Groups::get($groupID); - $libraryName = "the group '" . $group->name . "'"; - } - else { - $libraryName = "your personal library"; - } - } - else { - $libraryName = "one of your libraries"; - $itemKey = false; - } - $this->error(400, "ERROR_PROCESSING_UPLOAD_DATA", - "The $fieldName field value '{$value}…' in $libraryName is " - . "too long to sync to zotero.org.\n\n" - . "Search for the excerpt above " - . ($itemKey === false ? "using " : "or copy and paste " - . "'$itemKey' into ") . "the Zotero search bar. " - . "Shorten the field, or delete the item and empty the " - . "Zotero trash, and then try syncing again." - ); - } - break; - - case Z_ERROR_ARRAY_SIZE_MISMATCH: - $this->error(400, 'DATABASE_TOO_LARGE', - "Databases of this size cannot yet be synced. Please check back soon. (Report ID: $id)"); - break; - - case Z_ERROR_TAG_LINKED_ITEM_NOT_FOUND: - $this->error(400, 'WRONG_LIBRARY_TAG_ITEM', - "Error processing uploaded data (Report ID: $id)"); - break; - - case Z_ERROR_SHARD_READ_ONLY: - case Z_ERROR_SHARD_UNAVAILABLE: - $this->error(503, 'SERVER_ERROR', Z_CONFIG::$MAINTENANCE_MESSAGE); - break; - } - - if (strpos($msg, "Lock wait timeout exceeded; try restarting transaction") !== false - || strpos($msg, "MySQL error: Deadlock found when trying to get lock; try restarting transaction") !== false) { - $this->error(500, 'TIMEOUT', - "Sync upload timed out. Please try again in a few minutes. (Report ID: $id)"); - } - - if (strpos($msg, "Data too long for column 'xmldata'") !== false) { - $this->error(400, 'DATABASE_TOO_LARGE', - "Databases of this size cannot yet be synced. Please check back soon. (Report ID: $id)"); - } - - // On certain messages, send 400 to prevent auto-retry - if (strpos($msg, " too long") !== false - || strpos($msg, "First and last name are empty") !== false) { - $this->error(400, 'ERROR_PROCESSING_UPLOAD_DATA', - $explicit - ? $msg - : "Error processing uploaded data (Report ID: $id)" - ); - } - - if (preg_match("/Incorrect datetime value: '([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2})' " - . "for column 'date(Added|Modified)'/", $msg, $matches)) { - - if (isset($_SERVER['HTTP_X_ZOTERO_VERSION'])) { - require_once('../model/ToolkitVersionComparator.inc.php'); - - if (ToolkitVersionComparator::compare($_SERVER['HTTP_X_ZOTERO_VERSION'], "2.1rc1") < 0) { - $msg = "Invalid timestamp '{$matches[1]}' in uploaded data. Upgrade to Zotero 2.1rc1 when available to fix automatically."; - } - else { - $msg = "Invalid timestamp '{$matches[1]}' in uploaded data. Sync again to correct automatically."; - } - } - else { - $msg = "Invalid timestamp '{$matches[1]}' in uploaded data. Upgrade to Zotero 2.1rc1 when available to fix automatically."; - } - - $this->error(400, 'INVALID_TIMESTAMP', $msg); - } - - $this->error(500, 'ERROR_PROCESSING_UPLOAD_DATA', - $explicit - ? $msg - : "Error processing uploaded data (Report ID: $id)" - ); - } - - - private function end() { - if ($this->profile) { - Zotero_DB::profileEnd($this->profileShard, false); - } - - header("Content-Type: text/xml"); - $xmlstr = $this->responseXML->asXML(); - echo $xmlstr; - Z_Core::exitClean(); - } - - - private function error($httpCode=500, $code, $message=false, $attributes=array(), $elements=array()) { - header("Content-Type: text/xml"); - - if ($httpCode) { - header("HTTP/1.1 " . $httpCode); - } - - $this->responseXML->error['code'] = $code; - if ($message) { - $this->responseXML->error = $message; - } - foreach ($attributes as $attr=>$val) { - $this->responseXML->error[$attr] = $val; - } - foreach ($elements as $name=>$val) { - $this->responseXML->$name = $val; - } - - $xmlstr = $this->responseXML->asXML(); - - // Strip XML declaration, since it will be added automatically when in XML mode - $xmlstr = preg_replace("/<\?xml.+?>\n/", '', $xmlstr); - - echo $xmlstr; - - $this->logRequestTime(); - Z_Core::exitClean(); - } - - - private function currentRequestTime() { - return round(microtime(true) - $this->startTime, 2); - } - - - private function logRequestTime($point=false) { - if ($this->timeLogged) { - return; - } - $time = $this->currentRequestTime(); - if ($time > 5) { - $this->timeLogged = true; - error_log( - "Slow request " . ($point ? "at point " . $point : "") . ": " - . $time . " sec for " - . $_SERVER['REQUEST_METHOD'] . " " . $_SERVER['REQUEST_URI'] - ); - } - } - - - private function e409($message) { - header("HTTP/1.1 409 Conflict"); - die($message); - } -} -?> diff --git a/controllers/TagsController.php b/controllers/TagsController.php deleted file mode 100644 index c2a5fa05..00000000 --- a/controllers/TagsController.php +++ /dev/null @@ -1,162 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require('ApiController.php'); - -class TagsController extends ApiController { - public function tags() { - $this->allowMethods(['HEAD', 'GET', 'DELETE']); - - if (!$this->permissions->canAccess($this->objectLibraryID)) { - $this->e403(); - } - - if ($this->isWriteMethod()) { - // Check for library write access - if (!$this->permissions->canWrite($this->objectLibraryID)) { - $this->e403("Write access denied"); - } - - // Make sure library hasn't been modified - $this->checkLibraryIfUnmodifiedSinceVersion(true); - - Zotero_Libraries::updateVersionAndTimestamp($this->objectLibraryID); - } - - $tagIDs = array(); - $results = array(); - $name = $this->objectName; - $fixedValues = array(); - - $this->libraryVersion = Zotero_Libraries::getUpdatedVersion($this->objectLibraryID); - - // Set of tags matching name - if ($name && $this->subset != 'tags') { - $this->allowMethods(array('GET')); - - $tagIDs = Zotero_Tags::getIDs($this->objectLibraryID, $name); - if (!$tagIDs) { - $this->e404(); - } - - $title = "Tags matching ‘" . $name . "’"; - } - // All tags - else { - $this->allowMethods(array('GET', 'DELETE')); - - if ($this->scopeObject) { - $this->allowMethods(array('GET')); - - switch ($this->scopeObject) { - case 'collections': - $collection = Zotero_Collections::getByLibraryAndKey($this->objectLibraryID, $this->scopeObjectKey); - if (!$collection) { - $this->e404(); - } - $title = "Tags in Collection ‘" . $collection->name . "’"; - $counts = $collection->getTagItemCounts(); - $tagIDs = array(); - if ($counts) { - foreach ($counts as $tagID=>$count) { - $tagIDs[] = $tagID; - $fixedValues[$tagID] = array( - 'numItems' => $count - ); - } - } - break; - - case 'items': - $item = Zotero_Items::getByLibraryAndKey($this->objectLibraryID, $this->scopeObjectKey); - if (!$item) { - $this->e404(); - } - $title = "Tags of '" . $item->getDisplayTitle() . "'"; - $tagIDs = $item->getTags(true); - break; - - default: - throw new Exception("Invalid tags scope object '$this->scopeObject'"); - } - } - else if ($this->method == 'DELETE') { - // Filter for specific tags with "?tag=foo || bar" - $tagNames = !empty($this->queryParams['tag']) - ? explode(' || ', $this->queryParams['tag']): array(); - Zotero_DB::beginTransaction(); - foreach ($tagNames as $tagName) { - $tagIDs = Zotero_Tags::getIDs($this->objectLibraryID, $tagName); - foreach ($tagIDs as $tagID) { - $tag = Zotero_Tags::get($this->objectLibraryID, $tagID, true); - Zotero_Tags::delete($this->objectLibraryID, $tag->key, $this->objectUserID); - } - } - Zotero_DB::commit(); - $this->e204(); - } - else { - $title = "Tags"; - $results = Zotero_Tags::search($this->objectLibraryID, $this->queryParams); - } - } - - if ($tagIDs) { - $this->queryParams['tagIDs'] = $tagIDs; - $results = Zotero_Tags::search($this->objectLibraryID, $this->queryParams); - } - - $this->generateMultiResponse($results, $title, $fixedValues); - $this->end(); - } - - - private function generateMultiResponse($results, $title, $fixedValues) { - $options = [ - 'action' => $this->action, - 'uri' => $this->uri, - 'results' => $results, - 'requestParams' => $this->queryParams, - 'permissions' => $this->permissions, - 'head' => $this->method == 'HEAD' - ]; - switch ($this->queryParams['format']) { - case 'atom': - $this->responseXML = Zotero_API::multiResponse(array_merge($options, [ - 'title' => $this->getFeedNamePrefix($this->objectLibraryID) . $title, - 'fixedValues' => $fixedValues - ])); - break; - - case 'json': - Zotero_API::multiResponse($options); - break; - - default: - throw new Exception("Unexpected format '" . $this->queryParams['format'] . "'"); - } - } -} diff --git a/dataserver b/dataserver new file mode 160000 index 00000000..5fba6e4c --- /dev/null +++ b/dataserver @@ -0,0 +1 @@ +Subproject commit 5fba6e4c4a9df97a305764a69ebf70a872d38cd8 diff --git a/db.Dockerfile b/db.Dockerfile new file mode 100644 index 00000000..7d20a7bb --- /dev/null +++ b/db.Dockerfile @@ -0,0 +1,6 @@ + +############################ +# mariadb image +############################ + +FROM mariadb:10 \ No newline at end of file diff --git a/db.Dockerfile.dockerignore b/db.Dockerfile.dockerignore new file mode 100644 index 00000000..44d4ecf7 --- /dev/null +++ b/db.Dockerfile.dockerignore @@ -0,0 +1,20 @@ +**/secret.json +**/secret.txt +**/secret.yaml +.git +.github +.env +.vscode/ +bin +build +client +dataserver +doc +docker +docker-compose.yml +logs +stream-server +tinymce-clean-server +Zend +zotprime-k8s + diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 00000000..b6d44e4b --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,236 @@ +version: '3.9' +services: + zotprime-db: + image: "uniuu/zotprime-db:${VER}" +# image: mysql:5.6 + build: + context: . + dockerfile: db.Dockerfile + restart: always +# command: --default-authentication-plugin=mysql_native_password + environment: + - MARIADB_ROOT_PASSWORD=${MYSQLROOTPASSWORD} + - MARIADB_DATABASE=${MYSQLDATABASE} + - MARIADB_USER=${MYSQLUSER} + - MARIADB_PASSWORD=${MYSQLPASSWORD} + volumes: + - dbdata:/var/lib/mysql + - /etc/localtime:/etc/localtime:ro +# ulimits: +# nofile: +# soft: 262144 +# hard: 262144 + networks: + zotprime: + ipv4_address: 10.5.5.2 + zotprime-elasticsearch: + image: "uniuu/zotprime-elasticsearch:${VER}" + mem_limit: 512m + mem_reservation: 256M + build: + context: . + dockerfile: es.Dockerfile + environment: + - cluster.name=zotero + - xpack.security.enabled=false + - cluster.routing.allocation.disk.threshold_enabled=false + # - cluster.routing.allocation.disk.watermark.flood_stage=500mb + - discovery.type=single-node +# - "ES_JAVA_OPTS=-Xms256m -Xmx256m" + volumes: + - /etc/localtime:/etc/localtime:ro +# privileged: true + #user: root + #command: "sysctl -w vm.max_map_count=262144 && su elasticsearch -c bin/elasticsearch" + # sysctls: + # - vm.max_map_count=262144 +# command: "sysctl -w vm.max_map_count=262144" + networks: + zotprime: + ipv4_address: 10.5.5.3 + zotprime-redis: + image: "uniuu/zotprime-redis:${VER}" + build: + context: . + dockerfile: r.Dockerfile + # privileged: true + # user: root + # sysctls: + # - sysctl vm.overcommit_memory=1 + volumes: + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.4 + zotprime-memcached: + image: "uniuu/zotprime-memcached:${VER}" + build: + context: . + dockerfile: m.Dockerfile + volumes: + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.5 + zotprime-localstack: + image: "uniuu/zotprime-localstack:${VER}" + build: + context: . + dockerfile: ls.Dockerfile + environment: + - SERVICES=sns,sqs,apigateway + volumes: + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.6 + zotprime-minio: + image: "uniuu/zotprime-minio:${VER}" + build: + context: . + dockerfile: minio.Dockerfile + environment: + - MINIO_ROOT_USER=${MINIOROOTUSER} + - MINIO_ROOT_PASSWORD=${MINIOROOTPASSWORD} + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + volumes: + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.7 + zotprime-miniomc: + image: "uniuu/zotprime-miniomc:${VER}" + restart: always + build: + context: . + dockerfile: miniomc.Dockerfile + environment: + - MINIO_ROOT_USER=${MINIOROOTUSER} + - MINIO_ROOT_PASSWORD=${MINIOROOTPASSWORD} +# entrypoint: > +# /bin/sh -c " +# /usr/bin/mc config host add minio http://zotprime-minio:9000 ${MINIOROOTUSER} ${MINIOROOTPASSWORD}; +# exec mc admin trace -v -a minio; +# " +# exec sleep inf; + volumes: + - /etc/localtime:/etc/localtime:ro + depends_on: + - zotprime-minio + links: + - zotprime-minio:minio + networks: + zotprime: + ipv4_address: 10.5.5.12 + zotprime-dataserver: + image: "uniuu/zotprime-dataserver:${VER}" + build: + context: . + dockerfile: ds.Dockerfile +# extra_hosts: +# - "zotero.localhost:127.0.0.1" +# - "zotero-fulltext.localhost:127.0.0.1" + ports: + - "8080:80" + - "8082:8082" +# - "9000:9000" + volumes: + - "/etc/localtime:/etc/localtime:ro" + environment: + - RUN_USER=apache + - RUN_GROUP=apache + - DSURI=${DSHOST} + - S3POINTURI=${S3HOST} + depends_on: + - zotprime-db + - zotprime-elasticsearch + - zotprime-redis + - zotprime-memcached + - zotprime-tinymceclean + - zotprime-streamserver + links: + - zotprime-db:mysql + - zotprime-elasticsearch:elasticsearch + - zotprime-redis:redis + - zotprime-memcached:memcached + - zotprime-localstack:localstack + - zotprime-minio:minio + - zotprime-tinymceclean:tinymceclean + - zotprime-streamserver:streamserver + restart: always + networks: + zotprime: + aliases: + - dataserver + ipv4_address: 10.5.5.8 + zotprime-tinymceclean: + image: "uniuu/zotprime-tinymceclean:${VER}" + build: + context: . + dockerfile: tmcs.Dockerfile + depends_on: + - zotprime-db + - zotprime-elasticsearch + - zotprime-redis + - zotprime-memcached + links: + - zotprime-redis:redis + volumes: + - /etc/localtime:/etc/localtime:ro + restart: always + networks: + zotprime: + ipv4_address: 10.5.5.9 + zotprime-streamserver: + image: "uniuu/zotprime-streamserver:${VER}" + build: + context: . + dockerfile: sts.Dockerfile + ports: + - "8081:81" + volumes: +# - "./docker/stream-server/default.js:/usr/src/app/config/default.js:ro" + - /etc/localtime:/etc/localtime:ro + restart: always + depends_on: + - zotprime-db + - zotprime-elasticsearch + - zotprime-redis + - zotprime-memcached + links: + - zotprime-redis:redis + networks: + zotprime: + ipv4_address: 10.5.5.10 + zotprime-phpmyadmin: + image: "uniuu/zotprime-phpmyadmin:${VER}" + build: + context: . + dockerfile: pa.Dockerfile + ports: + - "8083:80" + environment: + - PMA_HOST=mysql + links: + - zotprime-db:mysql + volumes: + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.11 + + +volumes: + dbdata: + driver: local + +networks: + zotprime: + driver: bridge + ipam: + config: + - subnet: 10.5.5.0/28 + gateway: 10.5.5.1 \ No newline at end of file diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml new file mode 100644 index 00000000..7bb7dd01 --- /dev/null +++ b/docker-compose-prod.yml @@ -0,0 +1,155 @@ +version: '3.9' +services: + zotprime-db: + image: "uniuu/zotprime-db:${VER}" + restart: always + environment: + - MARIADB_ROOT_PASSWORD=${MYSQLROOTPASSWORD} + - MARIADB_DATABASE=${MYSQLDATABASE} + - MARIADB_USER=${MYSQLUSER} + - MARIADB_PASSWORD=${MYSQLPASSWORD} + volumes: + - dbdata:/var/lib/mysql + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.2 + zotprime-elasticsearch: + image: "uniuu/zotprime-elasticsearch:${VER}" + mem_limit: 512m + mem_reservation: 256M + environment: + - cluster.name=zotero + - xpack.security.enabled=false + - cluster.routing.allocation.disk.threshold_enabled=false + - discovery.type=single-node + volumes: + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.3 + zotprime-redis: + image: "uniuu/zotprime-redis:${VER}" + volumes: + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.4 + zotprime-memcached: + image: "uniuu/zotprime-memcached:${VER}" + volumes: + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.5 + zotprime-localstack: + image: "uniuu/zotprime-localstack:${VER}" + environment: + - SERVICES=sns,sqs,apigateway + volumes: + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.6 + zotprime-minio: + image: "uniuu/zotprime-minio:${VER}" + environment: + - MINIO_ROOT_USER=${MINIOROOTUSER} + - MINIO_ROOT_PASSWORD=${MINIOROOTPASSWORD} + command: server /data --console-address ":9001" + ports: + - "9001:9001" + volumes: + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.7 + zotprime-dataserver: + image: "uniuu/zotprime-dataserver:${VER}" + ports: + - "8080:80" + - "8082:8082" + volumes: + - "/etc/localtime:/etc/localtime:ro" + environment: + - RUN_USER=apache + - RUN_GROUP=apache + - DSURI=${DSHOST} + - S3POINTURI=${S3HOST} + depends_on: + - zotprime-db + - zotprime-elasticsearch + - zotprime-redis + - zotprime-memcached + - zotprime-tinymceclean + - zotprime-streamserver + links: + - zotprime-db:mysql + - zotprime-elasticsearch:elasticsearch + - zotprime-redis:redis + - zotprime-memcached:memcached + - zotprime-localstack:localstack + - zotprime-minio:minio + - zotprime-tinymceclean:tinymceclean + - zotprime-streamserver:streamserver + restart: always + networks: + zotprime: + aliases: + - dataserver + ipv4_address: 10.5.5.8 + zotprime-tinymceclean: + image: "uniuu/zotprime-tinymceclean:${VER}" + depends_on: + - zotprime-db + - zotprime-elasticsearch + - zotprime-redis + - zotprime-memcached + links: + - zotprime-redis:redis + volumes: + - /etc/localtime:/etc/localtime:ro + restart: always + networks: + zotprime: + ipv4_address: 10.5.5.9 + zotprime-streamserver: + image: "uniuu/zotprime-streamserver:${VER}" + ports: + - "8081:81" + volumes: + - /etc/localtime:/etc/localtime:ro + restart: always + depends_on: + - zotprime-db + - zotprime-elasticsearch + - zotprime-redis + - zotprime-memcached + links: + - zotprime-redis:redis + networks: + zotprime: + ipv4_address: 10.5.5.10 + zotprime-phpmyadmin: + image: "uniuu/zotprime-phpmyadmin:${VER}" + ports: + - "8083:80" + environment: + - PMA_HOST=mysql + links: + - zotprime-db:mysql + volumes: + - /etc/localtime:/etc/localtime:ro + networks: + zotprime: + ipv4_address: 10.5.5.11 +volumes: + dbdata: + driver: local +networks: + zotprime: + driver: bridge + ipam: + config: + - subnet: 10.5.5.0/28 + gateway: 10.5.5.1 \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 41d6a367..00000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,70 +0,0 @@ -# Use the official Docker Hub Ubuntu 18.04 base image -FROM ubuntu:18.04 - -# Update the base image -RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get -y upgrade && apt-get -y dist-upgrade - -# Setup PHP5 -RUN DEBIAN_FRONTEND=noninteractive apt-get -y install software-properties-common -RUN DEBIAN_FRONTEND=noninteractive add-apt-repository ppa:ondrej/php -RUN DEBIAN_FRONTEND=noninteractive apt-get update -RUN DEBIAN_FRONTEND=noninteractive apt-get -y install apache2 libapache2-mod-php7.0 sudo rsyslog wget mysql-client curl nodejs -RUN DEBIAN_FRONTEND=noninteractive apt-get -y install php7.0-cli php7.0-xml php7.0-mysql php7.0-pgsql php7.0-json php7.0-curl php7.0-mbstring php7.0-intl php7.0-redis php7.0-dev php-pear composer - -RUN sed -i 's/memory_limit = 128M/memory_limit = 1G/g' /etc/php/7.0/apache2/php.ini -RUN sed -i 's/max_execution_time = 30/max_execution_time = 300/g' /etc/php/7.0/apache2/php.ini -RUN sed -i 's/short_open_tag = Off/short_open_tag = On/g' /etc/php/7.0/apache2/php.ini -RUN sed -i 's/short_open_tag = Off/short_open_tag = On/g' /etc/php/7.0/cli/php.ini -RUN sed -i 's/display_errors = On/display_errors = Off/g' /etc/php/7.0/apache2/php.ini -RUN sed -i 's/error_reporting = E_ALL \& ~E_DEPRECATED \& ~E_STRICT/error_reporting = E_ALL \& ~E_NOTICE \& ~E_STRICT \& ~E_DEPRECATED/g' /etc/php/7.0/apache2/php.ini - -# Setup igbinary -RUN DEBIAN_FRONTEND=noninteractive pecl install igbinary -RUN echo "extension=igbinary.so" > /etc/php/7.0/mods-available/igbinary.ini -#RUN ln -s /etc/php/7.0/mods-available/igbinary.ini /etc/php/7.0/cli/conf.d/20-igbinary.ini -#RUN ln -s /etc/php/7.0/mods-available/igbinary.ini /etc/php/7.0/apache2/conf.d/20-igbinary.ini - -# Setup Memcached -RUN DEBIAN_FRONTEND=noninteractive apt-get -y install libmemcached11 libmemcachedutil2 build-essential libmemcached-dev libz-dev libxml2-dev zlib1g-dev libicu-dev g++ -RUN DEBIAN_FRONTEND=noninteractive pecl download memcached-3.0.4 && tar xvzf memcached-3.0.4.tgz && cd memcached-3.0.4 && phpize && ./configure --enable-memcached-igbinary && make && make install -RUN echo "extension=memcached.so" > /etc/php/7.0/mods-available/memcached.ini -RUN ln -s /etc/php/7.0/mods-available/memcached.ini /etc/php/7.0/cli/conf.d/20-memcached.ini -RUN ln -s /etc/php/7.0/mods-available/memcached.ini /etc/php/7.0/apache2/conf.d/20-memcached.ini - -# HTTP_Request2 -RUN DEBIAN_FRONTEND=noninteractive pear install HTTP_Request2 - -# Setup awscli -RUN DEBIAN_FRONTEND=noninteractive apt-get -y install python python-pip git wget npm rinetd -RUN DEBIAN_FRONTEND=noninteractive pip install awscli - -# Setup Apache2 -RUN a2enmod rewrite - -# Enable the new virtualhost -COPY zotero.conf /etc/apache2/sites-available/ -RUN a2dissite 000-default -RUN a2ensite zotero - -# Override gzip configuration -COPY gzip.conf /etc/apache2/conf-available/ -RUN a2enconf gzip - -# AWS local credentials -RUN mkdir ~/.aws && bash -c 'echo -e "[default]\nregion = us-east-1" > ~/.aws/config' && bash -c 'echo -e "[default]\naws_access_key_id = zotero\naws_secret_access_key = zoterodocker" > ~/.aws/credentials' - -# Chown log directory -RUN chown 33:33 /var/log/apache2 - -# Rinetd -RUN echo "0.0.0.0 8082 minio 9000" >> /etc/rinetd.conf - -# Expose and entrypoint -COPY entrypoint.sh / -RUN chmod +x /entrypoint.sh -VOLUME /var/www/zotero -EXPOSE 80/tcp -EXPOSE 81/TCP -EXPOSE 82/TCP -ENTRYPOINT ["/entrypoint.sh"] - diff --git a/docker/bin/init.sh b/docker/bin/init.sh deleted file mode 100755 index 1ff36055..00000000 --- a/docker/bin/init.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh - -sudo docker-compose exec app-zotero bash -c 'cd /var/www/zotero/misc && ./init-mysql.sh' -sudo docker-compose exec app-zotero bash -c 'aws --endpoint-url "http://minio:9000" s3 mb s3://zotero' -sudo docker-compose exec app-zotero bash -c 'aws --endpoint-url "http://minio:9000" s3 mb s3://zotero-fulltext' -sudo docker-compose exec app-zotero bash -c 'aws --endpoint-url "http://localstack:4575" sns create-topic --name zotero' diff --git a/docker/bin/run.sh b/docker/bin/run.sh deleted file mode 100755 index f140a7ac..00000000 --- a/docker/bin/run.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -sudo docker-compose up diff --git a/model/FullText.inc.php b/docker/dataserver/FullText.inc.php similarity index 52% rename from model/FullText.inc.php rename to docker/dataserver/FullText.inc.php index 1202d99c..9edc3d7a 100644 --- a/model/FullText.inc.php +++ b/docker/dataserver/FullText.inc.php @@ -28,7 +28,7 @@ class Zotero_FullText { private static $minFileSizeStandardIA = 75 * 1024; private static $elasticsearchType = "item_fulltext"; - public static $metadata = array('indexedChars', 'totalChars', 'indexedPages', 'totalPages'); + public static $metadata = ['indexedChars', 'totalChars', 'indexedPages', 'totalPages']; public static function indexItem(Zotero_Item $item, $data) { if (!$item->isAttachment()) { @@ -48,8 +48,8 @@ public static function indexItem(Zotero_Item $item, $data) { // Add to MySQL for syncing, since Elasticsearch doesn't refresh immediately $sql = "REPLACE INTO itemFulltext ("; - $fields = ["itemID", "version", "timestamp"]; - $params = [$item->id, $version, $timestamp]; + $fields = ["itemID", "libraryID", "version", "timestamp"]; + $params = [$item->id, $libraryID, $version, $timestamp]; $sql .= implode(", ", $fields) . ") VALUES (" . implode(', ', array_fill(0, sizeOf($params), '?')) . ")"; Zotero_DB::query($sql, $params, Zotero_Shards::getByLibraryID($libraryID)); @@ -79,18 +79,13 @@ public static function indexItem(Zotero_Item $item, $data) { 'Key' => $libraryID . "/" . $key, 'Body' => $json, 'ContentType' => 'application/gzip', - 'StorageClass' => strlen($json) < self::$minFileSizeStandardIA ? 'STANDARD' : 'STANDARD_IA' + 'StorageClass' => 'STANDARD' ]); StatsD::timing("s3.fulltext.put", (microtime(true) - $start) * 1000); Zotero_DB::commit(); - - // Todo: Remove fall back code after migration - $redis = Z_Redis::get('fulltext-migration'); - $redis->set('s3:' . $libraryID . "/" . $key, '1'); } - public static function updateMultipleFromJSON($json, $requestParams, $libraryID, $userID, Zotero_Permissions $permissions) { self::validateMultiObjectJSON($json); @@ -123,7 +118,7 @@ public static function updateMultipleFromJSON($json, $requestParams, $libraryID, // This shouldn't happen, since the request uses a library version if (!$item) { throw new Exception( - "Item $jsonObject->key not found in library", + "Item $libraryID/$jsonObject->key not found in library", Z_ERROR_ITEM_NOT_FOUND ); } @@ -135,6 +130,8 @@ public static function updateMultipleFromJSON($json, $requestParams, $libraryID, $results->addSuccessful($i, $obj); } catch (Exception $e) { + Z_Core::debug($e->getMessage()); + Zotero_DB::rollback(); // If item key given, include that @@ -147,7 +144,6 @@ public static function updateMultipleFromJSON($json, $requestParams, $libraryID, return $results->generateReport(); } - private static function validateMultiObjectJSON($json) { if (!is_array($json)) { throw new Exception('Uploaded data must be a JSON array', Z_ERROR_INVALID_INPUT); @@ -158,43 +154,14 @@ private static function validateMultiObjectJSON($json) { } } - /* - * TODO: Remove fall back code after migration - */ - public static function getItemDataES($libraryID, $key) { - $index = self::getReadIndex(); - $type = self::getReadType(); - $id = $libraryID . "/" . $key; - - try { - $document = $type->getDocument($id, [ - 'routing' => $libraryID - ]); - } - catch (\Elastica\Exception\NotFoundException $e) { - return false; - } - - $esData = $document->getData(); - $itemData = array( - "libraryID" => $libraryID, - "key" => $key, - "content" => $esData['content'], - "version" => $esData['version'], - ); - if (isset($esData['language'])) { - $itemData['language'] = $esData['language']; - } - foreach (self::$metadata as $prop) { - $itemData[$prop] = isset($esData[$prop]) ? $esData[$prop] : 0; - } - return $itemData; - } - /** * Get item full-text data from S3 by libraryID and key + * + * @param {$libraryID} + * @param {$key} + * @return {object|null} */ - public static function getItemDataS3($libraryID, $key) { + public static function getItemData($libraryID, $key) { $s3Client = Z_Core::$AWS->createS3(); try { @@ -207,7 +174,7 @@ public static function getItemDataS3($libraryID, $key) { } catch (Aws\S3\Exception\S3Exception $e) { if ($e->getAwsErrorCode() == 'NoSuchKey') { - return false; + return null; } throw $e; } @@ -220,12 +187,12 @@ public static function getItemDataS3($libraryID, $key) { $json = json_decode($json); - $itemData = array( + $itemData = [ "libraryID" => $libraryID, "key" => $key, "content" => $json->content, "version" => $json->version - ); + ]; if (isset($json->language)) { $itemData['language'] = $json->language; } @@ -235,24 +202,12 @@ public static function getItemDataS3($libraryID, $key) { return $itemData; } - /* - * TODO: Remove fall back code after migration - */ - public static function getItemData($libraryID, $key) { - $data = self::getItemDataS3($libraryID, $key); - if (!$data) { - $data = self::getItemDataES($libraryID, $key); - } - return $data; - } - - /** * @return {Object} An object with item keys for keys and full-text content versions for values */ public static function getNewerInLibrary($libraryID, $version) { - $sql = "SELECT `key`, IFT.version FROM itemFulltext IFT JOIN items USING (itemID) " - . "WHERE libraryID=? AND IFT.version>?"; + $sql = "SELECT `key`, IFT.version FROM itemFulltext IFT JOIN items I USING (itemID) " + . "WHERE IFT.libraryID=? AND IFT.version>?"; $rows = Zotero_DB::query( $sql, [$libraryID, $version], @@ -265,116 +220,48 @@ public static function getNewerInLibrary($libraryID, $version) { return $versions; } - - /** - * Used by classic sync - * - * @return {Array} Array of arrays of item data - */ - public static function getNewerInLibraryByTime($libraryID, $timestamp, $keys=[]) { - $sql = "(SELECT libraryID, `key` FROM itemFulltext JOIN items USING (itemID) " - . "WHERE libraryID=? AND timestamp>=FROM_UNIXTIME(?))"; - $params = [$libraryID, $timestamp]; - if ($keys) { - $sql .= " UNION " - . "(SELECT libraryID, `key` FROM itemFulltext JOIN items USING (itemID) " - . "WHERE libraryID=? AND `key` IN (" - . implode(', ', array_fill(0, sizeOf($keys), '?')) . ")" - . ")"; - $params = array_merge($params, [$libraryID], $keys); - } - $rows = Zotero_DB::query( - $sql, $params, Zotero_Shards::getByLibraryID($libraryID) - ); - if (!$rows) { - return []; - } - - $maxChars = 1000000; - - $first = true; - $stop = false; - $chars = 0; - $data = []; - - while (($chars < $maxChars) && ($row = array_shift($rows)) && !$stop) { - $libraryID = $row['libraryID']; - $key = $row['key']; - - $data[$key] = self::getItemData($libraryID, $key); - if (!$data[$key]) { - error_log("WARNING: JSON " . $libraryID . "/" . $key . " not found in S3 bucket"); - continue; - } - - // If the current item would put us over max characters, - // leave it empty, unless it's the first one - $currentChars = strlen($data[$key]['content']); - if (!$first && (($chars + $currentChars) > $maxChars)) { - unset($data[$key]['content']); - $data[$key]['empty'] = true; - $stop = true; - } - else { - $data[$key]['empty'] = false; - $chars += $currentChars; - } - $first = false; - } - - // Add unprocessed rows as empty - foreach ($rows as $row) { - $data[$row['key']] = [ - "libraryID" => $row['libraryID'], - "key" => $row['key'], - "version" => 0, - "indexedChars" => 0, - "totalChars" => 0, - "indexedPages" => 0, - "empty" => true - ]; - } - - return $data; - } - - /** * @param {Integer} libraryID * @param {String} searchText - * @return {Array|Boolean} An array of item keys, or FALSE if no results + * @return {Array|null} An array of item keys, or null if no results */ public static function searchInLibrary($libraryID, $searchText) { - // TEMP: For now, strip double-quotes and make everything a phrase search - $searchText = str_replace('"', '', $searchText); - - $type = self::getReadType(); - - $libraryFilter = new \Elastica\Filter\Term(); - $libraryFilter->setTerm("libraryID", $libraryID); - - $matchQuery = new \Elastica\Query\Match(); - $matchQuery->setFieldQuery('content', $searchText); - $matchQuery->setFieldType('content', 'phrase'); + $params = [ + 'index' => self::$elasticsearchType . "_index", + 'type' => self::$elasticsearchType, + 'routing' => $libraryID, + 'size' => 150, + "body" => [ + 'query' => [ + 'bool' => [ + 'must' => [ + 'match_phrase_prefix' => [ + 'content' => $searchText + ] + ], + 'filter' => [ + 'term' => [ + 'libraryID' => $libraryID + ] + ] + ] + ] + ] + ]; - $matchQuery = new \Elastica\Query\Filtered($matchQuery, $libraryFilter); $start = microtime(true); - $resultSet = $type->search($matchQuery, [ - 'routing' => $libraryID - ]); + $resp = Z_Core::$ES->search($params); StatsD::timing("elasticsearch.client.item_fulltext.search", (microtime(true) - $start) * 1000); - if ($resultSet->getResponse()->hasError()) { - throw new Exception($resultSet->getResponse()->getError()); - } - $results = $resultSet->getResults(); - $keys = array(); + + $results = $resp['hits']['hits']; + + $keys = []; foreach ($results as $result) { - $keys[] = explode("/", $result->getId())[1]; + $keys[] = explode("/", $result['_id'])[1]; } return $keys; } - public static function deleteItemContent(Zotero_Item $item) { $libraryID = $item->libraryID; $key = $item->key; @@ -399,11 +286,6 @@ public static function deleteItemContent(Zotero_Item $item) { StatsD::timing("s3.fulltext.delete", (microtime(true) - $start) * 1000); Zotero_DB::commit(); - - // Todo: Remove after migration - // Make sure the full-text won't be recreated when doing migration - $redis = Z_Redis::get('fulltext-migration'); - $redis->set('s3:' . $libraryID . "/" . $key, '1'); } public static function deleteByLibrary($libraryID) { @@ -420,86 +302,12 @@ public static function deleteByLibrary($libraryID) { StatsD::timing("s3.fulltext.bulk_delete", (microtime(true) - $start) * 1000); Zotero_DB::commit(); - - // Todo: Remove after migration - // Make sure the library full-texts won't be recreated when doing migration - $redis = Z_Redis::get('fulltext-migration'); - $redis->set('s3:' . $libraryID, '1'); } - public static function deleteByLibraryMySQL($libraryID) { - $sql = "DELETE IFT FROM itemFulltext IFT JOIN items USING (itemID) WHERE libraryID=?"; + $sql = "DELETE FROM itemFulltext WHERE libraryID=?"; Zotero_DB::query( $sql, $libraryID, Zotero_Shards::getByLibraryID($libraryID) ); } - - - public static function indexFromXML(DOMElement $xml, $userID) { - if ($xml->textContent === "") { - error_log("Skipping empty full-text content for item " - . $xml->getAttribute('libraryID') . "/" . $xml->getAttribute('key')); - return; - } - $item = Zotero_Items::getByLibraryAndKey( - $xml->getAttribute('libraryID'), $xml->getAttribute('key') - ); - if (!$item) { - error_log("Item " . $xml->getAttribute('libraryID') . "/" . $xml->getAttribute('key') - . " not found during full-text indexing"); - return; - } - if (!Zotero_Libraries::userCanEdit($item->libraryID, $userID)) { - error_log("Skipping full-text content from user $userID for uneditable item " - . $xml->getAttribute('libraryID') . "/" . $xml->getAttribute('key')); - return; - } - $data = new stdClass; - $data->content = $xml->textContent; - foreach (self::$metadata as $prop) { - $data->$prop = $xml->getAttribute($prop); - } - self::indexItem($item, $data); - } - - - /** - * @param {Array} $data Item data from Elasticsearch - * @param {DOMDocument} $doc - * @param {Boolean} [$empty=false] If true, don't include full-text content - */ - public static function itemDataToXML($data, DOMDocument $doc, $empty=false) { - $xmlNode = $doc->createElement('fulltext'); - $xmlNode->setAttribute('libraryID', $data['libraryID']); - $xmlNode->setAttribute('key', $data['key']); - foreach (self::$metadata as $prop) { - $xmlNode->setAttribute($prop, isset($data[$prop]) ? $data[$prop] : 0); - } - $xmlNode->setAttribute('version', $data['version']); - if (!$empty) { - $xmlNode->appendChild($doc->createTextNode($data['content'])); - } - return $xmlNode; - } - - - private static function getReadIndex() { - return Z_Core::$Elastica->getIndex(self::$elasticsearchType . "_index_read"); - } - - - private static function getWriteIndex() { - return Z_Core::$Elastica->getIndex(self::$elasticsearchType . "_index_write"); - } - - - private static function getReadType() { - return new \Elastica\Type(self::getReadIndex(), self::$elasticsearchType); - } - - - private static function getWriteType() { - return new \Elastica\Type(self::getWriteIndex(), self::$elasticsearchType); - } } diff --git a/model/Storage.inc.php b/docker/dataserver/Storage.inc.php similarity index 91% rename from model/Storage.inc.php rename to docker/dataserver/Storage.inc.php index c3179a25..b598adfe 100644 --- a/model/Storage.inc.php +++ b/docker/dataserver/Storage.inc.php @@ -32,7 +32,7 @@ class Zotero_Storage { public static function getDownloadDetails($item) { // TODO: get attachment link mode value from somewhere - if (!$item->isAttachment() || !$item->isImportedAttachment()) { + if (!$item->isAttachment() || !$item->isStoredFileAttachment()) { return false; } $sql = "SELECT storageFileID FROM storageFileItems WHERE itemID=?"; @@ -179,35 +179,6 @@ public static function downloadFile(array $localFileItemInfo, $savePath, $filena } } - public static function logDownload($item, $downloadUserID, $ipAddress) { - $libraryID = $item->libraryID; - $ownerUserID = Zotero_Libraries::getOwner($libraryID); - - $info = self::getLocalFileItemInfo($item); - $storageFileID = $info['storageFileID']; - $filename = $info['filename']; - $size = $info['size']; - - $sql = "INSERT INTO storageDownloadLog - (ownerUserID, downloadUserID, ipAddress, storageFileID, filename, size) - VALUES (?, ?, INET_ATON(?), ?, ?, ?)"; - Zotero_DB::query( - $sql, - [ - $ownerUserID, - $downloadUserID, - $ipAddress, - $storageFileID, - $filename, - $size - ], - 0, - [ - 'writeInReadMode' => true - ] - ); - } - public static function uploadFile(Zotero_StorageFileInfo $info, $file, $contentType) { if (!file_exists($file)) { @@ -219,7 +190,8 @@ public static function uploadFile(Zotero_StorageFileInfo $info, $file, $contentT 'SourceFile' => $file, 'Bucket' => Z_CONFIG::$S3_BUCKET, 'Key' => $info->hash, - 'ACL' => 'private' + 'ACL' => 'private', + 'StorageClass' => 'INTELLIGENT_TIERING' ]); return self::addFile($info); @@ -277,32 +249,14 @@ public static function getUploadInfo($key) { } - public static function logUpload($uploadUserID, $item, $key, $ipAddress) { - $libraryID = $item->libraryID; - $ownerUserID = Zotero_Libraries::getOwner($libraryID); - - $info = self::getUploadInfo($key); - if (!$info) { - throw new Exception("Upload key '$key' not found in queue"); - } - - $info = self::getLocalFileItemInfo($item); - $storageFileID = $info['storageFileID']; - $filename = $info['filename']; - $size = $info['size']; - + public static function markUploadAsCompleted($key) { $sql = "DELETE FROM storageUploadQueue WHERE uploadKey=?"; Zotero_DB::query($sql, $key); - - $sql = "INSERT INTO storageUploadLog - (ownerUserID, uploadUserID, ipAddress, storageFileID, filename, size) - VALUES (?, ?, INET_ATON(?), ?, ?, ?)"; - Zotero_DB::query($sql, array($ownerUserID, $uploadUserID, $ipAddress, $storageFileID, $filename, $size)); } public static function getUploadBaseURL() { - return "http://" . Z_CONFIG::$S3_ENDPOINT . "/" . Z_CONFIG::$S3_BUCKET . "/"; + return "http://" . Z_CONFIG::$S3_ENDPOINT . "/" . Z_CONFIG::$S3_BUCKET . "/"; //return "https://" . Z_CONFIG::$S3_BUCKET . ".s3.amazonaws.com/"; } @@ -583,7 +537,7 @@ public static function addFile(Zotero_StorageFileInfo $info) { public static function updateFileItemInfo($item, $storageFileID, Zotero_StorageFileInfo $info, $client=false) { - if (!$item->isImportedAttachment()) { + if (!$item->isStoredFileAttachment()) { throw new Exception("Cannot add storage file for linked file/URL"); } @@ -689,7 +643,7 @@ public static function getUploadPOSTData($item, Zotero_StorageFileInfo $info) { . "Content-Disposition: form-data; name=\"$key\"\r\n\r\n" . $val . "\r\n"; } - $prefix .= "--$boundary\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\n"; + $prefix .= "--$boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"binary\"\r\n\r\n"; // Suffix $suffix = "\r\n--$boundary--"; @@ -715,6 +669,7 @@ public static function generateUploadPOSTParams($item, Zotero_StorageFileInfo $i switch ($linkMode) { case "imported_file": case "imported_url": + case "embedded_image": break; default: @@ -841,11 +796,17 @@ public static function generateUploadPOSTParams($item, Zotero_StorageFileInfo $i } + /** + * Get quota and expiration date for a user + */ public static function getUserValues($userID) { $sql = "SELECT quota, UNIX_TIMESTAMP(expiration) AS expiration FROM storageAccounts WHERE userID=?"; return Zotero_DB::rowQuery($sql, $userID); } + /** + * Set quota and expiration date for a user + */ public static function setUserValues($userID, $quota, $expiration) { $cacheKey = "userStorageQuota_" . $userID; Z_Core::$MC->delete($cacheKey); @@ -858,8 +819,11 @@ public static function setUserValues($userID, $quota, $expiration) { // If changing quota, make sure it's not less than current usage $current = self::getUserValues($userID); - $usage = self::getUserUsage($userID); - if ($current['quota'] != $quota && $quota < $usage['total']) { + $usage = self::getUserUsage($userID, 'mb'); + // Allow usage a bit above the quota, to account for upload race conditions that result in + // a last file going above the quota + $leeway = 10; + if (($current['quota'] ?? 0) != $quota && ($quota + $leeway) < $usage['total']) { throw new Exception("Cannot set quota below current usage", Z_ERROR_GROUP_QUOTA_SET_BELOW_USAGE); } @@ -877,7 +841,7 @@ public static function setUserValues($userID, $quota, $expiration) { public static function getInstitutionalUserQuota($userID) { // TODO: config - $dev = Z_ENV_TESTING_SITE ? "_test" : ""; + $dev = Z_ENV_TESTING_SITE ? "_dev" : ""; $databaseName = "zotero_www{$dev}"; // Get maximum institutional quota by e-mail domain @@ -887,7 +851,7 @@ public static function getInstitutionalUserQuota($userID) { // Domain is treated as a fully matching regexp with an implied optional // subdomain prefix. 'mail.school.edu' will match 'school.edu' or // '(school.edu|school.org)', but 'abcd.edu' won't match 'bcd.edu'. - . "AND SUBSTRING_INDEX(email, '@', -1) REGEXP CONCAT('^(.+\\.)?', domain, '$')" + . "AND SUBSTRING_INDEX(email, '@', -1) REGEXP CONCAT('^(.+\\\.)?', domain, '$')" // Email doesn't match blacklist if one exists for domain . "AND (domainBlacklist = '' " . "OR SUBSTRING_INDEX(email, '@', -1) NOT REGEXP domainBlacklist)" @@ -944,20 +908,20 @@ public static function getEffectiveUserQuota($userID) { return $quota; } - public static function getUserUsage($userID) { - $cacheKey = "userStorageUsage_" . $userID; + public static function getUserUsage($userID, $unit = 'b') { + $cacheKey = "userStorageUsageBytes_" . $userID; $usage = Z_Core::$MC->get($cacheKey); if ($usage) { + $usage = self::usageToUnit($usage, $unit); return $usage; } $usage = []; $libraryID = Zotero_Users::getLibraryIDFromUserID($userID); - $sql = "SELECT SUM(size) AS bytes FROM storageFileItems " - . "JOIN items USING (itemID) WHERE libraryID=?"; + $sql = "SELECT storageUsage FROM shardLibraries WHERE libraryID=?"; $libraryBytes = Zotero_DB::valueQuery($sql, $libraryID, Zotero_Shards::getByLibraryID($libraryID)); - $usage['library'] = round($libraryBytes / 1024 / 1024, 1); + $usage['library'] = $libraryBytes; $groupBytes = 0; $usage['groups'] = array(); @@ -967,18 +931,17 @@ public static function getUserUsage($userID) { $shardIDs = Zotero_Groups::getUserGroupShards($userID); foreach ($shardIDs as $shardID) { - $sql = "SELECT libraryID, SUM(size) AS `bytes` FROM storageFileItems - JOIN items I USING (itemID) - WHERE libraryID IN - (" . implode(', ', array_fill(0, sizeOf($ownedLibraries), '?')) . ") - GROUP BY libraryID WITH ROLLUP"; + $sql = "SELECT libraryID, storageUsage AS `bytes` FROM shardLibraries + WHERE libraryID IN + (" . implode(', ', array_fill(0, sizeOf($ownedLibraries), '?')) . ") + GROUP BY libraryID WITH ROLLUP"; $libraries = Zotero_DB::query($sql, $ownedLibraries, $shardID); if ($libraries) { foreach ($libraries as $library) { if ($library['libraryID']) { $usage['groups'][] = array( 'id' => Zotero_Groups::getGroupIDFromLibraryID($library['libraryID']), - 'usage' => round($library['bytes'] / 1024 / 1024, 1) + 'usage' => $library['bytes'] ); } // ROLLUP row @@ -990,16 +953,17 @@ public static function getUserUsage($userID) { } } - $usage['total'] = round(($libraryBytes + $groupBytes) / 1024 / 1024, 1); + $usage['total'] = $libraryBytes + $groupBytes; - Z_Core::$MC->set($cacheKey, $usage, 600); + Z_Core::$MC->set($cacheKey, $usage, 1200); + $usage = self::usageToUnit($usage, $unit); return $usage; } public static function clearUserUsage($userID) { - $cacheKey = "userStorageUsage_" . $userID; + $cacheKey = "userStorageUsageBytes_" . $userID; Z_Core::$MC->delete($cacheKey); } @@ -1010,6 +974,25 @@ private static function updateLastAdded($storageFileID) { } + private static function usageToUnit($usage, $unit) { + if ($unit == 'b') { + return $usage; + } + if ($unit == 'mb') { + $divisor = 1024 * 1024; + } + else { + throw new Exception("Invalid unit $unit"); + } + $usage['library'] = round($usage['library'] / $divisor, 1); + foreach ($usage['groups'] as &$value) { + $value['usage'] = round($value['usage'] / $divisor, 1); + } + $usage['total'] = round($usage['total'] / $divisor, 1); + return $usage; + } + + private static function getHash($stringToSign) { return base64_encode(hash_hmac('sha1', $stringToSign, Z_CONFIG::$AWS_SECRET_KEY, true)); } diff --git a/docker/config/config.inc.php b/docker/dataserver/config.inc.php similarity index 80% rename from docker/config/config.inc.php rename to docker/dataserver/config.inc.php index d77c5d1a..d60d80f8 100644 --- a/docker/config/config.inc.php +++ b/docker/dataserver/config.inc.php @@ -1,20 +1,21 @@ [ @@ -52,7 +55,7 @@ class Z_CONFIG { ); public static $TRANSLATION_SERVERS = array( - "translation1.localdomain:1969" + "http://translation1.localdomain:1969" ); public static $CITATION_SERVERS = array( @@ -65,10 +68,12 @@ class Z_CONFIG { public static $ATTACHMENT_SERVER_HOSTS = array("files1.localdomain", "files2.localdomain"); public static $ATTACHMENT_SERVER_DYNAMIC_PORT = 80; - public static $ATTACHMENT_SERVER_STATIC_PORT = 81; +// public static $ATTACHMENT_SERVER_STATIC_PORT = 81; public static $ATTACHMENT_SERVER_URL = "https://files.example.net"; public static $ATTACHMENT_SERVER_DOCROOT = "/var/www/attachments/"; - + public static $ATTACHMENT_PROXY_URL = "https://files.example.com/"; + public static $ATTACHMENT_PROXY_SECRET = ""; + public static $STATSD_ENABLED = false; public static $STATSD_PREFIX = ""; public static $STATSD_HOST = "monitor.localdomain"; @@ -80,7 +85,7 @@ class Z_CONFIG { public static $LOG_TIMEZONE = 'US/Eastern'; public static $LOG_TARGET_DEFAULT = 'errors'; - public static $HTMLCLEAN_SERVER_URL = 'http://localhost:16342'; + public static $HTMLCLEAN_SERVER_URL = 'http://tinymceclean:16342'; public static $PROCESSOR_PORT_DOWNLOAD = 3455; public static $PROCESSOR_PORT_UPLOAD = 3456; @@ -97,10 +102,14 @@ class Z_CONFIG { public static $CLI_PHP_PATH = '/usr/bin/php'; public static $SYNC_ERROR_PATH = '/var/log/apache2/'; + + // Alternative to S3_BUCKET_ERRORS public static $ERROR_PATH = '/var/log/apache2/'; public static $CACHE_VERSION_ATOM_ENTRY = 1; public static $CACHE_VERSION_BIB = 1; public static $CACHE_VERSION_ITEM_DATA = 1; + public static $CACHE_VERSION_RESPONSE_JSON_COLLECTION = 1; + public static $CACHE_VERSION_RESPONSE_JSON_ITEM = 1; } ?> diff --git a/admin/create-user.sh b/docker/dataserver/create-user.sh similarity index 100% rename from admin/create-user.sh rename to docker/dataserver/create-user.sh diff --git a/docker/config/dbconnect.inc.php b/docker/dataserver/dbconnect.inc.php similarity index 100% rename from docker/config/dbconnect.inc.php rename to docker/dataserver/dbconnect.inc.php diff --git a/docker/dataserver/entrypoint.sh b/docker/dataserver/entrypoint.sh new file mode 100755 index 00000000..55f05618 --- /dev/null +++ b/docker/dataserver/entrypoint.sh @@ -0,0 +1,75 @@ +#!/bin/sh +set -eux +# Env vars +#export APACHE_RUN_USER=${RUN_USER} +#export APACHE_RUN_GROUP=${RUN_GROUP} +#export APACHE_LOCK_DIR=/var/lock/apache2 +#export APACHE_PID_FILE=/var/run/apache2/apache2.pid +#export APACHE_RUN_DIR=/var/run/apache2 +#export APACHE_LOG_DIR=/var/log/apache2 + +# Start log +#/etc/init.d/rsyslog start + +# Start rinetd +#echo "logfile /dev/stdout" >> /etc/rinetd.conf + +#(until rinetd -f -c /etc/rinetd.conf; do +# echo "'rinetd' crashed with exit code $?. Restarting..." >&2 +# sleep 1 +#done) & + +#(while true; do +# rinetd -f -c /etc/rinetd.conf +#done) & + +#rinetd -f -c /etc/rinetd.conf & +#/etc/init.d/rinetd start + +#a2enmod headers +#a2enmod rewrite +#a2dissite 000-default +#a2ensite zotero +#a2enconf gzip + +# NPM +#cd /var/www/zotero/stream-server +#npm cache clean --force +#npx npm-check-updates -u +#npm update +#npm install +#npm update +#yarn add uWebSockets.js@uNetworking/uWebSockets.js#v20.23.0 + +#cd /var/www/zotero/tinymce-clean-server && npm install + +# Start Stream server +#cd /var/www/zotero/stream-server && node index.js & + +# Start Clean server +#cd /var/www/zotero/tinymce-clean-server && node server.js & + +# Chown +#chown -R --no-dereference ${RUN_USER}:${RUN_GROUP} /var/log/apache2 + +# Chmod +chmod 777 /var/www/zotero/tmp + +sed -i 's/AGPL-3.0"/AGPL-3.0-only"/g' /var/www/zotero/composer.json + +sed -i "s#http://localhost:8080/#$DSURI#g" /var/www/zotero/include/config/config.inc.php +sed -i "s#10.5.5.1:9000#$S3POINTURI#g" /var/www/zotero/include/config/config.inc.php +sed -i "s#10.5.5.1:9000#$S3POINTURI#g" /var/www/zotero/include/Zend/Service/Amazon/S3.php + + +# Elastica Composer +#cd /var/www/zotero/include/Elastica && composer -v install + +# Composer +cd /var/www/zotero && composer update && composer -vv install +#cd /var/www/zotero && composer -vv install + +# Start Apache2 +#exec httpd -e debug -DNO_DETACH -k start +exec httpd -e debug -DFOREGROUND -k start +#exec httpd -DFOREGROUND -k start diff --git a/docker/gzip.conf b/docker/dataserver/gzip.conf similarity index 100% rename from docker/gzip.conf rename to docker/dataserver/gzip.conf diff --git a/include/header.inc.php b/docker/dataserver/header.inc.php similarity index 92% rename from include/header.inc.php rename to docker/dataserver/header.inc.php index b6bb3a16..0e8e70cf 100644 --- a/include/header.inc.php +++ b/docker/dataserver/header.inc.php @@ -73,13 +73,6 @@ function zotero_autoload($className) { require_once $className . '.inc.php'; return; } - - // Elastica - if (strpos($className, 'Elastica\\') === 0) { - $className = str_replace('\\', '/', $className); - require_once 'Elastica/lib/' . $className . '.php'; - return; - } } spl_autoload_register('zotero_autoload'); @@ -89,7 +82,7 @@ function zotero_autoload($className) { if (Z_Core::isCommandLine()) { $_SERVER['DOCUMENT_ROOT'] = realpath(dirname(dirname(__FILE__))) . '/'; - $_SERVER['SERVER_NAME'] = Z_CONFIG::$SYNC_DOMAIN; + $_SERVER['SERVER_NAME'] = parse_url(Z_CONFIG::$API_BASE_URI)['host']; $_SERVER['HTTP_HOST'] = $_SERVER['SERVER_NAME']; $_SERVER['REQUEST_URI'] = "/"; } @@ -156,6 +149,7 @@ function zotero_autoload($className) { ini_set("display_errors", "1"); //error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE | E_STRICT); error_reporting(-1); + ini_set('html_errors', '0'); define('Z_ENV_DEV_SITE', !empty(Z_CONFIG::$DEV_SITE)); } @@ -198,7 +192,7 @@ function zotero_autoload($className) { // Memcached require('Memcached.inc.php'); Z_Core::$MC = new Z_MemcachedClientLocal( - Z_CONFIG::$SYNC_DOMAIN, + Z_CONFIG::$API_BASE_URI, array( 'disabled' => !Z_CONFIG::$MEMCACHED_ENABLED, 'servers' => Z_CONFIG::$MEMCACHED_SERVERS @@ -216,13 +210,13 @@ function zotero_autoload($className) { 'version' => 'latest', 'signature' => 'v4', 'endpoint' => 'http://' . Z_CONFIG::$S3_ENDPOINT, + 'use_path_style_endpoint' => true, 'scheme' => 'http', 'http' => [ 'timeout' => 3 ], 'retries' => 2 ]; - // IAM role authentication if (empty(Z_CONFIG::$AWS_ACCESS_KEY)) { // If APC cache is available, use that to cache temporary credentials @@ -245,18 +239,11 @@ function zotero_autoload($className) { Z_Core::$AWS = new Aws\Sdk($awsConfig); unset($awsConfig); -// Elastica -$searchHosts = array_map(function ($hostAndPort) { - preg_match('/^([^:]+)(:[0-9]+)?$/', $hostAndPort, $matches); - return [ - 'host' => $matches[1], - 'port' => isset($matches[2]) ? $matches[2] : 9200 - ]; -}, Z_CONFIG::$SEARCH_HOSTS); -shuffle($searchHosts); -Z_Core::$Elastica = new \Elastica\Client([ - 'connections' => $searchHosts -]); +// Elasticsearch +$esConfig = [ + 'hosts' => Z_CONFIG::$SEARCH_HOSTS +]; +Z_Core::$ES = \Elasticsearch\ClientBuilder::fromConfig($esConfig, true); require('interfaces/IAuthenticationPlugin.inc.php'); diff --git a/docker/zotero.conf b/docker/dataserver/zotero.conf similarity index 94% rename from docker/zotero.conf rename to docker/dataserver/zotero.conf index 7a8719f1..a970fbc1 100644 --- a/docker/zotero.conf +++ b/docker/dataserver/zotero.conf @@ -10,5 +10,6 @@ NameVirtualHost * AllowOverride All Order allow,deny Allow from all + Require all granted diff --git a/docker/db/db_update.sh b/docker/db/db_update.sh new file mode 100755 index 00000000..35f21eb8 --- /dev/null +++ b/docker/db/db_update.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -uex + +MYSQL="mysql -h mysql -P 3306 -u root -pzotero" +echo "ALTER TABLE libraries ADD hasData TINYINT( 1 ) NOT NULL DEFAULT '0' AFTER version , ADD INDEX ( hasData )" | $MYSQL zotero_master +echo "UPDATE libraries SET hasData=1 WHERE version > 0 OR lastUpdated != '0000-00-00 00:00:00'" | $MYSQL zotero_master +echo "ALTER TABLE libraries DROP COLUMN lastUpdated, DROP COLUMN version" | $MYSQL zotero_master diff --git a/docker/db/db_update2.sh b/docker/db/db_update2.sh new file mode 100755 index 00000000..b1d73ebd --- /dev/null +++ b/docker/db/db_update2.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -xue + +for i in db-updates/*/; do + cd /var/www/zotero/misc/$i + for j in *; do + find . -type f \( ! -name *.sql \) -exec php {} \; + find . -type f -name *.sql -exec bash -c 'mysql -h mysql -P 3306 -u root -pzotero zotero_master < {}' \; + done +done; +cd ../../ \ No newline at end of file diff --git a/misc/init-mysql.sh b/docker/db/init-mysql.sh similarity index 88% rename from misc/init-mysql.sh rename to docker/db/init-mysql.sh index f37a5399..91d45f2c 100755 --- a/misc/init-mysql.sh +++ b/docker/db/init-mysql.sh @@ -1,5 +1,8 @@ #!/bin/sh +set -ux + + MYSQL="mysql -h mysql -P 3306 -u root -pzotero" echo "DROP DATABASE IF EXISTS zotero_master" | $MYSQL @@ -26,7 +29,7 @@ echo "INSERT INTO shards VALUES (2, 1, 'zotero_shard_2', 'up', '1');" | $MYSQL z # Create first group & user echo "INSERT INTO libraries VALUES (1, 'user', '0000-00-00 00:00:00', 0, 1)" | $MYSQL zotero_master echo "INSERT INTO libraries VALUES (2, 'group', '0000-00-00 00:00:00', 0, 2)" | $MYSQL zotero_master -echo "INSERT INTO users VALUES (1, 1, 'admin', '0000-00-00 00:00:00', '0000-00-00 00:00:00')" | $MYSQL zotero_master +echo "INSERT INTO users VALUES (1, 1, 'admin')" | $MYSQL zotero_master echo "INSERT INTO groups VALUES (1, 2, 'Shared', 'shared', 'Private', 'members', 'all', 'members', '', '', 0, '0000-00-00 00:00:00', '0000-00-00 00:00:00', 1)" | $MYSQL zotero_master echo "INSERT INTO groupUsers VALUES (1, 1, 'owner', '0000-00-00 00:00:00', '0000-00-00 00:00:00')" | $MYSQL zotero_master @@ -44,8 +47,8 @@ cat triggers.sql | $MYSQL zotero_shard_1 cat shard.sql | $MYSQL zotero_shard_2 cat triggers.sql | $MYSQL zotero_shard_2 -echo "INSERT INTO shardLibraries VALUES (1, 'user', 0, 0)" | $MYSQL zotero_shard_1 -echo "INSERT INTO shardLibraries VALUES (2, 'group', 0, 0)" | $MYSQL zotero_shard_2 +echo "INSERT INTO shardLibraries VALUES (1, 'user', '0000-00-00 00:00:00', 0, 0)" | $MYSQL zotero_shard_1 +echo "INSERT INTO shardLibraries VALUES (2, 'group', '0000-00-00 00:00:00', 0, 0)" | $MYSQL zotero_shard_2 # Load in schema on id servers $MYSQL zotero_ids < ids.sql diff --git a/misc/shard.sql b/docker/db/shard.sql similarity index 81% rename from misc/shard.sql rename to docker/db/shard.sql index b979d4a3..b3490e86 100644 --- a/misc/shard.sql +++ b/docker/db/shard.sql @@ -22,16 +22,13 @@ -- ***** END LICENSE BLOCK ***** -SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO"; - - -- --- IMPORTANT: All tables added here must be added to Zotero_Shards::moveLibrary()! +-- IMPORTANT: All tables added here must be added to Zotero_Shards::copyLibrary()! -- CREATE TABLE `collectionItems` ( `collectionID` int(10) unsigned NOT NULL, - `itemID` int(10) unsigned NOT NULL, + `itemID` bigint unsigned NOT NULL, `orderIndex` mediumint(8) unsigned DEFAULT NULL, PRIMARY KEY (`collectionID`,`itemID`), KEY `itemID` (`itemID`) @@ -58,7 +55,7 @@ CREATE TABLE `collections` ( CREATE TABLE `creators` ( - `creatorID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `creatorID` bigint unsigned NOT NULL AUTO_INCREMENT, `libraryID` int(10) unsigned NOT NULL, `creatorDataHash` char(32) CHARACTER SET ascii DEFAULT NULL, `firstName` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, @@ -71,21 +68,35 @@ CREATE TABLE `creators` ( `serverDateModifiedMS` smallint(4) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`creatorID`), UNIQUE KEY `key` (`libraryID`,`key`), - KEY `hash` (`libraryID`,`creatorDataHash`(5)) + KEY `hash` (`libraryID`,`creatorDataHash`(5)), + KEY `name` (`libraryID`,`lastName`(7),`firstName`(6)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `deletedCollections` ( + `collectionID` int(10) unsigned NOT NULL, + `dateDeleted` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`collectionID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + CREATE TABLE `deletedItems` ( - `itemID` int(10) unsigned NOT NULL, + `itemID` bigint unsigned NOT NULL, `dateDeleted` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`itemID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `deletedSearches` ( + `searchID` int(10) unsigned NOT NULL, + `dateDeleted` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`searchID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + CREATE TABLE `groupItems` ( - `itemID` int(10) unsigned NOT NULL, + `itemID` bigint unsigned NOT NULL, `createdByUserID` int(10) unsigned DEFAULT NULL, `lastModifiedByUserID` int(10) unsigned DEFAULT NULL, PRIMARY KEY (`itemID`), @@ -95,15 +106,15 @@ CREATE TABLE `groupItems` ( CREATE TABLE `publicationsItems` ( - `itemID` int(10) unsigned NOT NULL, + `itemID` bigint unsigned NOT NULL, PRIMARY KEY (`itemID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `itemAttachments` ( - `itemID` int(10) unsigned NOT NULL, - `sourceItemID` int(10) unsigned DEFAULT NULL, - `linkMode` enum('IMPORTED_FILE','IMPORTED_URL','LINKED_FILE','LINKED_URL') NOT NULL, + `itemID` bigint unsigned NOT NULL, + `sourceItemID` bigint unsigned DEFAULT NULL, + `linkMode` enum('IMPORTED_FILE','IMPORTED_URL','LINKED_FILE','LINKED_URL','EMBEDDED_IMAGE'), `mimeType` varchar(255) NOT NULL, `charsetID` tinyint(3) unsigned DEFAULT NULL, `path` blob NOT NULL, @@ -115,10 +126,25 @@ CREATE TABLE `itemAttachments` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +CREATE TABLE `itemAnnotations` ( + `itemID` bigint unsigned NOT NULL, + `parentItemID` bigint unsigned NOT NULL, + `type` enum('highlight','note','image', 'ink') CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `authorName` varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL DEFAULT '', + `text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL, + `comment` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL, + `color` char(6) CHARACTER SET ascii NOT NULL, + `pageLabel` varchar(50) NOT NULL, + `sortIndex` varchar(18) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `position` text CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + PRIMARY KEY (`itemID`), + KEY `parentItemID` (`parentItemID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + CREATE TABLE `itemCreators` ( - `itemID` int(10) unsigned NOT NULL, - `creatorID` int(10) unsigned NOT NULL, + `itemID` bigint unsigned NOT NULL, + `creatorID` bigint unsigned NOT NULL, `creatorTypeID` smallint(5) unsigned NOT NULL, `orderIndex` smallint(5) unsigned NOT NULL, PRIMARY KEY (`itemID`,`creatorID`,`orderIndex`), @@ -129,7 +155,7 @@ CREATE TABLE `itemCreators` ( CREATE TABLE `itemData` ( - `itemID` int(10) unsigned NOT NULL, + `itemID` bigint unsigned NOT NULL, `fieldID` smallint(5) unsigned NOT NULL, `itemDataValueHash` char(32) CHARACTER SET ascii DEFAULT NULL, `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci, @@ -139,16 +165,18 @@ CREATE TABLE `itemData` ( CREATE TABLE `itemFulltext` ( - `itemID` int(10) unsigned NOT NULL, + `itemID` bigint unsigned NOT NULL, + `libraryID` int(10) unsigned NOT NULL, `version` int(10) unsigned NOT NULL DEFAULT '0', `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`itemID`) + PRIMARY KEY (`itemID`), + KEY `libraryVersion` (`libraryID`,`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `itemNotes` ( - `itemID` int(10) unsigned NOT NULL, - `sourceItemID` int(10) unsigned DEFAULT NULL, + `itemID` bigint unsigned NOT NULL, + `sourceItemID` bigint unsigned DEFAULT NULL, `note` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL, `noteSanitized` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NULL, `title` varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL, @@ -160,8 +188,8 @@ CREATE TABLE `itemNotes` ( CREATE TABLE `itemRelated` ( - `itemID` int(10) unsigned NOT NULL, - `linkedItemID` int(10) unsigned NOT NULL, + `itemID` bigint unsigned NOT NULL, + `linkedItemID` bigint unsigned NOT NULL, PRIMARY KEY (`itemID`,`linkedItemID`), KEY `linkedItemID` (`linkedItemID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; @@ -169,7 +197,7 @@ CREATE TABLE `itemRelated` ( CREATE TABLE `itemSortFields` ( - `itemID` int(10) unsigned NOT NULL, + `itemID` bigint unsigned NOT NULL, `sortTitle` varchar(79) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NULL DEFAULT NULL, `creatorSummary` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NULL DEFAULT NULL, PRIMARY KEY (`itemID`), @@ -180,7 +208,7 @@ CREATE TABLE `itemSortFields` ( CREATE TABLE `items` ( - `itemID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `itemID` bigint unsigned NOT NULL AUTO_INCREMENT, `libraryID` int(10) unsigned NOT NULL, `itemTypeID` smallint(5) unsigned NOT NULL, `dateAdded` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', @@ -197,7 +225,7 @@ CREATE TABLE `items` ( CREATE TABLE `itemTags` ( - `itemID` int(10) unsigned NOT NULL, + `itemID` bigint unsigned NOT NULL, `tagID` int(10) unsigned NOT NULL, PRIMARY KEY (`itemID`,`tagID`), KEY `tagID` (`tagID`) @@ -205,6 +233,14 @@ CREATE TABLE `itemTags` ( +CREATE TABLE `itemTopLevel` ( + `itemID` bigint unsigned NOT NULL, + `topLevelItemID` bigint unsigned NOT NULL, + PRIMARY KEY (`itemID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + + CREATE TABLE `relations` ( `relationID` int(10) unsigned NOT NULL AUTO_INCREMENT, `libraryID` int(10) unsigned NOT NULL, @@ -250,7 +286,7 @@ CREATE TABLE `savedSearches` ( CREATE TABLE `settings` ( `libraryID` int(10) unsigned NOT NULL, - `name` varchar(25) NOT NULL, + `name` varchar(35) NOT NULL, `value` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, `version` int(10) unsigned NOT NULL, `lastUpdated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -259,10 +295,11 @@ CREATE TABLE `settings` ( CREATE TABLE `shardLibraries` ( - `libraryID` int(10) unsigned NOT NULL, + `libraryID` int unsigned NOT NULL, `libraryType` enum('user','group','publications') NOT NULL, `lastUpdated` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - `version` int(10) unsigned NOT NULL DEFAULT '1', + `version` int unsigned NOT NULL DEFAULT '1', + `storageUsage` bigint NOT NULL DEFAULT '0', PRIMARY KEY (`libraryID`), KEY `libraryType` (`libraryType`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; @@ -270,7 +307,7 @@ CREATE TABLE `shardLibraries` ( CREATE TABLE `storageFileItems` ( `storageFileID` int(10) unsigned NOT NULL, - `itemID` int(10) unsigned NOT NULL, + `itemID` bigint unsigned NOT NULL, `mtime` bigint(13) unsigned NOT NULL, `size` int(10) unsigned NOT NULL, PRIMARY KEY (`storageFileID`,`itemID`), @@ -297,11 +334,13 @@ CREATE TABLE `syncDeleteLogKeys` ( `key` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `version` int(10) unsigned NOT NULL DEFAULT '1', + `data` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`libraryID`,`objectType`,`key`), KEY `objectType` (`objectType`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; + CREATE TABLE `tags` ( `tagID` int(10) unsigned NOT NULL AUTO_INCREMENT, `libraryID` int(10) unsigned NOT NULL, @@ -349,15 +388,25 @@ ALTER TABLE `collections` ALTER TABLE `creators` ADD CONSTRAINT `creators_ibfk_1` FOREIGN KEY (`libraryID`) REFERENCES `shardLibraries` (`libraryID`) ON DELETE CASCADE; +ALTER TABLE `deletedCollections` + ADD CONSTRAINT `deletedCollections_ibfk_1` FOREIGN KEY (`collectionID`) REFERENCES `collections` (`collectionID`) ON DELETE CASCADE; + ALTER TABLE `deletedItems` ADD CONSTRAINT `deletedItems_ibfk_1` FOREIGN KEY (`itemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE; +ALTER TABLE `deletedSearches` + ADD CONSTRAINT `deletedSearches_ibfk_1` FOREIGN KEY (`searchID`) REFERENCES `savedSearches` (`searchID`) ON DELETE CASCADE; + ALTER TABLE `groupItems` ADD CONSTRAINT `groupItems_ibfk_1` FOREIGN KEY (`itemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE; ALTER TABLE `publicationsItems` ADD CONSTRAINT `publicationsItems_ibfk_1` FOREIGN KEY (`itemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE; +ALTER TABLE `itemAnnotations` + ADD CONSTRAINT `itemAnnotations_ibfk_1` FOREIGN KEY (`itemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE, + ADD CONSTRAINT `itemAnnotations_ibfk_2` FOREIGN KEY (`parentItemID`) REFERENCES `itemAttachments` (`itemID`); + ALTER TABLE `itemAttachments` ADD CONSTRAINT `itemAttachments_ibfk_1` FOREIGN KEY (`itemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE, ADD CONSTRAINT `itemAttachments_ibfk_2` FOREIGN KEY (`sourceItemID`) REFERENCES `items` (`itemID`) ON DELETE SET NULL; @@ -390,6 +439,10 @@ ALTER TABLE `itemTags` ADD CONSTRAINT `itemTags_ibfk_1` FOREIGN KEY (`itemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE, ADD CONSTRAINT `itemTags_ibfk_2` FOREIGN KEY (`tagID`) REFERENCES `tags` (`tagID`) ON DELETE CASCADE; +ALTER TABLE `itemTopLevel` + ADD CONSTRAINT `itemTopLevel_ibfk_1` FOREIGN KEY (`itemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE, + ADD CONSTRAINT `itemTopLevel_ibfk_2` FOREIGN KEY (`topLevelItemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE; + ALTER TABLE `relations` ADD CONSTRAINT `relations_ibfk_1` FOREIGN KEY (`libraryID`) REFERENCES `shardLibraries` (`libraryID`) ON DELETE CASCADE; @@ -399,6 +452,7 @@ ALTER TABLE `savedSearchConditions` ALTER TABLE `savedSearches` ADD CONSTRAINT `savedSearches_ibfk_1` FOREIGN KEY (`libraryID`) REFERENCES `shardLibraries` (`libraryID`) ON DELETE CASCADE; +-- Keep in sync with fkd_items_storageUsage trigger ALTER TABLE `storageFileItems` ADD CONSTRAINT `storageFileItems_ibfk_2` FOREIGN KEY (`itemID`) REFERENCES `items` (`itemID`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/misc/www.sql b/docker/db/www.sql similarity index 100% rename from misc/www.sql rename to docker/db/www.sql diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml deleted file mode 100644 index f7008641..00000000 --- a/docker/docker-compose.yml +++ /dev/null @@ -1,66 +0,0 @@ -version: '3' -services: - db-zotero-mysql: - image: mysql:5.6 - command: --default-authentication-plugin=mysql_native_password - environment: - - MYSQL_ROOT_PASSWORD=zotero - db-zotero-elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:5.3.0 - environment: - - cluster.name=zotero - - xpack.security.enabled=false - db-zotero-redis: - image: redis:5.0 - db-zotero-memcached: - image: memcached:1.5 - db-zotero-localstack: - image: atlassianlabs/localstack - environment: - - SERVICES=sns,sqs,apigateway - db-zotero-minio: - image: minio/minio - build: - context: ./minio - environment: - - MINIO_ACCESS_KEY=zotero - - MINIO_SECRET_KEY=zoterodocker - command: server /data - app-zotero: - image: app-zotero - build: - context: . - dockerfile: Dockerfile - ports: - - "8080:80" - - "8081:81" - - "8082:8082" - volumes: - - "../:/var/www/zotero:rw" - - "./config/config.inc.php:/var/www/zotero/include/config/config.inc.php:ro" - - "./config/dbconnect.inc.php:/var/www/zotero/include/config/dbconnect.inc.php:ro" - - "./config/default.js:/var/www/zotero/stream-server/config/default.js:ro" - environment: - - RUN_USER=www-data - - RUN_GROUP=www-data - depends_on: - - db-zotero-mysql - - db-zotero-elasticsearch - - db-zotero-redis - - db-zotero-memcached - links: - - db-zotero-mysql:mysql - - db-zotero-elasticsearch:elasticsearch - - db-zotero-redis:redis - - db-zotero-memcached:memcached - - db-zotero-localstack:localstack - - db-zotero-minio:minio - restart: always - app-zotero-phpmyadmin: - image: phpmyadmin/phpmyadmin - ports: - - "8083:80" - environment: - - PMA_HOST=mysql - links: - - db-zotero-mysql:mysql diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100755 index ab28c1f0..00000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/sh - -# Env vars -export APACHE_RUN_USER=${RUN_USER} -export APACHE_RUN_GROUP=${RUN_GROUP} -export APACHE_LOCK_DIR=/var/lock/apache2 -export APACHE_PID_FILE=/var/run/apache2/apache2.pid -export APACHE_RUN_DIR=/var/run/apache2 -export APACHE_LOG_DIR=/var/log/apache2 - -# Start log -/etc/init.d/rsyslog start - -# Start rinetd -/etc/init.d/rinetd start - -# NPM -cd /var/www/zotero/stream-server && npm install -cd /var/www/zotero/tinymce-clean-server && npm install - -# Start Stream server -cd /var/www/zotero/stream-server && nodejs index.js & - -# Start Clean server -cd /var/www/zotero/tinymce-clean-server && nodejs server.js & - -# Chown -chown -R ${RUN_USER}:${RUN_GROUP} /var/log/apache2 - -# Chmod -chmod 777 /var/www/zotero/tmp - -# Composer -cd /var/www/zotero && composer install - -# Start Apache2 -exec apache2 -DNO_DETACH -k start diff --git a/docker/minio/Dockerfile b/docker/minio/Dockerfile deleted file mode 100644 index 8dc8ba59..00000000 --- a/docker/minio/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM minio/minio - -COPY minio /usr/bin/ -RUN chmod +x /usr/bin/minio diff --git a/docker/minio/minio b/docker/minio/minio deleted file mode 100755 index e21313ba..00000000 Binary files a/docker/minio/minio and /dev/null differ diff --git a/docker/miniomc/entrypoint.sh b/docker/miniomc/entrypoint.sh new file mode 100755 index 00000000..cd4ac98a --- /dev/null +++ b/docker/miniomc/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -eux + + +#export APACHE_RUN_USER=${RUN_USER} + +#(until rinetd -f -c /etc/rinetd.conf; do +# echo "'rinetd' crashed with exit code $?. Restarting..." >&2 +# sleep 1 +#done) & + +#exec httpd -e debug -DFOREGROUND -k start + +if [ -e tmp/_key/secret-minio.txt ] +then + source tmp/_key/secret-minio.txt +fi + +host=minio +port=9000 +echo -n "waiting for TCP connection to $host:$port..." +#while ! nc -w 1 $host $port 2>/dev/null +while ! curl -s $host:$port 1>/dev/null +do + echo -n . + sleep 1 +done +echo 'ok' + +/usr/bin/mc config host add minio http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD}; +exec mc admin trace -v -a minio; \ No newline at end of file diff --git a/docker/config/default.js b/docker/stream-server/default.js similarity index 91% rename from docker/config/default.js rename to docker/stream-server/default.js index a892565e..228bc020 100644 --- a/docker/config/default.js +++ b/docker/stream-server/default.js @@ -12,12 +12,12 @@ var config = { statusInterval: 10, keepaliveInterval: 25, retryTime: 10, - shutdownDelay: 100, + //shutdownDelay: 100, redis: { host: 'redis', prefix: '' }, - apiURL: 'http://127.0.0.1/', + apiURL: 'http://dataserver/', apiVersion: 3, apiRequestHeaders: {}, longStackTraces: false, @@ -30,7 +30,7 @@ var config = { globalTopicsMinDelay: 30 * 1000, // Notification action period -- clients are given a randomly chosen delay within this time // period before they should act upon the notification, so that we don't DDoS ourselves - globalTopicsDelayPeriod: 60 * 1000, + globalTopicsDelayPeriod: 180 * 1000, continuedDelayDefault: 3 * 1000, continuedDelay: 30 * 1000, statsD: { diff --git a/ds.Dockerfile b/ds.Dockerfile new file mode 100644 index 00000000..1a730ca5 --- /dev/null +++ b/ds.Dockerfile @@ -0,0 +1,360 @@ +#FROM alpine:3 AS builder +#ARG ZOTPRIME_VERSION=2 +##RUN apk add gnu-libiconv --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/community/ --allow-untrusted +##ENV LD_PRELOAD /usr/lib/preloadable_libiconv.so php +#RUN set -eux; \ +# apk update && apk upgrade --available +#RUN set -eux \ +# && apk add --no-cache \ +# python3 \ +# py3-pip \ +# build-base \ +# git \ +# autoconf \ +# automake \ +## util-linux \ +# && cd /tmp \ +# && git clone --depth=1 "https://github.com/samhocevar/rinetd" \ +# && cd rinetd \ +# && ./bootstrap \ +# && ./configure --prefix=/usr \ +# && make -j $(nproc) \ +# && strip rinetd \ +## && pip3 install --upgrade pip \ +## && pip3 install -v --no-cache-dir \ +## awscli \ +# && rm -rf /var/cache/apk/* +FROM alpine:3 +ARG ZOTPRIME_VERSION=2 + +#FROM php:8.1-alpine +#FROM php:alpine +#COPY --from=builder /tmp/rinetd/rinetd /usr/sbin/rinetd + + +#COPY --from=builder /usr/lib/preloadable_libiconv.so /usr/lib/preloadable_libiconv.so + +#COPY --from=builder /usr/bin/aws /usr/bin/aws + +#libapache2-mod-php7.0 +#php7.0-mysql php7.0-pgsql php7.0-redis php7.0-dev php-pear composer + + +#RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" +#RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" # DEV + + +#RUN set -eux; \ +# apk add gnu-libiconv --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/community/ --allow-untrusted + +#ENV LD_PRELOAD /usr/lib/preloadable_libiconv.so php + + +#RUN set -eux; \ +# apk update && apk upgrade --available; \ +# apk add --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/community/ --allow-untrusted \ +# php82-apache2 \ +# php82-bcmath \ +# php82-common \ +# php82-cli \ +# php82-dev \ +# php82-pdo_pgsql \ +# php82-pear \ +# php82-pgsql \ +# php82-tidy \ +# php82-zip \ +# php82-pecl-msgpack \ +# php82-pecl-xdebug; \ +RUN set -eux; \ + apk update && apk upgrade --available; \ + apk add --update --no-cache \ + apache2 \ + apache2-utils \ + aws-cli \ + bash \ + curl \ + gettext-libs \ + git \ + gnutls-utils \ + icu-libs \ + libmemcached \ + libxslt \ + mariadb-client \ + memcached \ +# mysql-client \ + net-tools \ + php81 \ + php81-apache2 \ + php81-bcmath \ + php81-calendar \ + php81-cli \ + php81-common \ + php81-ctype \ + php81-curl \ + php81-dev \ + php81-dom \ + php81-exif \ + php81-ffi \ + php81-ftp \ + php81-gettext \ + php81-iconv \ + php81-intl \ + php81-json \ + php81-mbstring \ + php81-mysqli \ + php81-opcache \ + php81-pcntl \ + php81-pdo_mysql \ + php81-pdo_pgsql \ + php81-pear \ + php81-pecl-igbinary \ +# php81-pecl-mcrypt \ + php81-pecl-memcached \ + php81-pecl-msgpack \ + php81-pecl-redis \ + php81-pecl-xdebug \ + php81-pgsql \ + php81-phar \ + php81-posix \ + php81-session \ + php81-sodium \ + php81-shmop \ + php81-simplexml \ + php81-sockets \ + php81-sysvmsg \ + php81-sysvsem \ + php81-sysvshm \ + php81-tidy \ + php81-tokenizer \ + php81-xml \ + php81-xmlreader \ + php81-xmlwriter \ + php81-xsl \ + php81-zip \ + php81-zlib \ + runit \ + sudo \ + unzip \ + uwsgi \ + wget \ +# && rm -rf /var/cache/apk/* +#RUN set -eux; \ +# apk update && apk upgrade --available \ +# && apk add --update --no-cache \ +# autoconf \ +# dpkg-dev \ +# file \ +# g++ \ +# gcc \ +# libc-dev \ +# libmemcached-dev \ +# make \ +# pcre-dev icu-dev gettext-dev libxslt-dev libmemcached-dev libffi-dev ${PHPIZE_DEPS}; \ +# && pear channel-update pear.php.net; \ +# pecl channel-update pecl.php.net; \ +# pear config-set php_ini /etc/php81/php.ini; \ +# pear install http_request2 \ +# pecl install redis igbinary; \ +# pecl install --configureoptions 'with-libmemcached-dir="no" with-zlib-dir="no" with-system-fastlz="no" enable-memcached-igbinary="yes" enable-memcached-msgpack="no" enable-memcached-json="no" enable-memcached-protocol="no" enable-memcached-sasl="yes" enable-memcached-session="yes"' memcached \ + && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ + && composer require --no-plugins --no-scripts pear/http_request2 \ +# docker-php-ext-install -j$(nproc) mysqli intl pdo_mysql gettext sysvshm sysvsem sysvmsg xsl pcntl calendar exif ffi shmop sockets opcache; \ +# docker-php-ext-enable calendar memcached mysqli redis gettext intl pdo_mysql sysvshm sysvsem sysvmsg xsl pcntl igbinary exif ffi shmop sockets opcache; \ +# && apk del autoconf dpkg-dev file g++ gcc libc-dev make \ +# apk del pcre-dev icu-dev gettext-dev libxslt-dev libmemcached-dev libffi-dev ${PHPIZE_DEPS} \ +# && apk del ${BUILD_DEPENDS} \ +# && docker-php-source delete \ + && rm -rf /tmp/pear \ + && rm -rf /var/cache/apk/* + + +# libc6 \ +# libdigest-hmac-perl \ +# libfile-util-perl \ +# libjson-xs-perl \ +# libplack-perl \ +# libswitch-perl +# mariadb-server \ +# nodejs \ +# npm \ +# vim \ +# awscli \ +#build-essential +# apache2-http2 \ +# ca-certificates \ +# libapache2-mod-php \ +# libapache2-mod-uwsgi \ +# rinetd \ +# rsyslog \ +# util-linux \ +# uwsgi-plugin-psgi \ + +#RUN set -eux; \ +# docker-php-ext-install iconv + +#RUN set -eux; \ +# docker-php-ext-install redis + +#RUN set -eux; \ +# docker-php-source delete \ +# && apk del ${BUILD_DEPENDS} + +#RUN pecl install ZendOpcache +#RUN docker-php-ext-install pgsql +#RUN docker-php-ext-install pdo_pgsql +#RUN docker-php-ext-install igbinary +#RUN docker-php-ext-install memcached + + +#RUN ls -lha /etc/ +#RUN ls -lha /etc/php82/conf.d +#RUN ls -lha /etc/php82/php.ini +#RUN docker-php-ext-enable pdo_pgsql +#RUN docker-php-ext-enable pgsql +#RUN docker-php-ext-enable ZendOpcache + +#RUN pecl install -o -f redis \ +#&& rm -rf /tmp/pear \ +#&& docker-php-ext-enable redis + +#Debug Rsyslog +#RUN sed -i '/^DAEMON=\/usr\/sbin\/rsyslogd/a RSYSLOGD_OPTIONS="-dn"' /etc/init.d/rsyslog + +#DEBUGGING +#RUN php -i +#RUN php -v +#RUN php --ini +#RUN php -m +#RUN composer show -p +#RUN aws --version +#RUN set -eux; \ +# ls -lha /etc/php81 + + +#RUN grep -R 'display_errors' /usr/local/etc/php +#RUN ls -lha /usr/local/etc/php +#RUN httpd -M +#RUN ls -lha /usr/lib/apache2/ +#RUN find / -name mod_php81.so +#RUN ls -lha /etc/apache2/ +#RUN grep -R 'LoadModule' /etc/ +#RUN ls -lha /etc/apache2/conf.d/ +#RUN ls -lha /usr/local/etc/php/conf.d/ +#RUN whereis php + +#RUN ls -lha /etc/php81/conf.d +#RUN ls -lha /usr/local/etc/php +#RUN ls -lha /etc/php81/php.ini +#RUN grep -R 'display_errors' /etc/ +#RUN grep -R 'log_errors' /etc/ +#RUN grep -R 'display_startup_errors' /etc/ +#RUN grep -R -A 10 -B 10 'error_reporting' /etc/ +#RUN php --ri iconv +#RUN ls -lha /usr/bin/php +#RUN php -i | grep iconv + + + + +RUN set -eux; \ + sed -i "s/#LoadModule\ rewrite_module/LoadModule\ rewrite_module/" /etc/apache2/httpd.conf; \ + sed -i "s/#LoadModule\ headers_module/LoadModule\ headers_module/" /etc/apache2/httpd.conf; \ +# sed -i "s/#LoadModule\ session_module/LoadModule\ session_module/" /etc/apache2/httpd.conf; \ +# sed -i "s/#LoadModule\ session_cookie_module/LoadModule\ session_cookie_module/" /etc/apache2/httpd.conf; \ +# sed -i "s/#LoadModule\ session_crypto_module/LoadModule\ session_crypto_module/" /etc/apache2/httpd.conf; \ + sed -i "s/#LoadModule\ deflate_module/LoadModule\ deflate_module/" /etc/apache2/httpd.conf; +# sed -i "s#^DocumentRoot \".*#DocumentRoot \"/var/www/zotero/htdocs\"#g" /etc/apache2/httpd.conf; \ +# sed -i "s#/var/www/localhost/htdocs#/var/www/zotero/htdocs#" /etc/apache2/httpd.conf; \ +# printf "\n\n\tAllowOverride All\n\n" >> /etc/apache2/httpd.conf + +RUN set -eux; \ + sed -i 's/memory_limit = 128M/memory_limit = 1G/g' /etc/php81/php.ini; \ + sed -i 's/max_execution_time = 30/max_execution_time = 300/g' /etc/php81/php.ini; \ + sed -i 's/short_open_tag = Off/short_open_tag = On/g' /etc/php81/php.ini; \ + sed -i 's/display_errors = On/display_errors = Off/g' /etc/php81/php.ini; \ +# sed -i 's/display_errors = Off/display_errors = On/g' /etc/php81/php.ini; \ + sed -i 's/error_reporting = E_ALL \& ~E_DEPRECATED \& ~E_STRICT/error_reporting = E_ALL \& ~E_NOTICE \& ~E_STRICT \& ~E_DEPRECATED/g' /etc/php81/php.ini +# sed -i 's/error_reporting = E_ALL \& ~E_DEPRECATED \& ~E_STRICT/error_reporting = E_ALL \& ~E_NOTICE/g' /etc/php81/php.ini +# sed -i 's/error_reporting = E_ALL \& ~E_DEPRECATED \& ~E_STRICT/error_reporting = E_ALL \| E_NOTICE \| E_WARNING/g' /etc/php81/php.ini + +# Enable the new virtualhost +COPY docker/dataserver/zotero.conf /etc/apache2/conf.d/ + +# Override gzip configuration +COPY docker/dataserver/gzip.conf /etc/apache2/conf.d/ + +# AWS local credentials +RUN set -eux; \ + mkdir ~/.aws \ + && /bin/sh -c 'echo -e "[default]\nregion = us-east-1" > ~/.aws/config' \ + && /bin/sh -c 'echo -e "[default]\naws_access_key_id = zotero\naws_secret_access_key = zoterodocker" > ~/.aws/credentials' + + + +RUN set -eux; \ + rm -rvf /var/log/apache2; \ + mkdir -p /var/log/apache2; \ +# Chown log directory + chown 100:101 /var/log/apache2; \ +# Apache logs print docker logs + ln -sfT /dev/stdout /var/log/apache2/access.log; \ + ln -sfT /dev/stderr /var/log/apache2/error.log; \ + ln -sfT /dev/stdout /var/log/apache2/other_vhosts_access.log; \ +# Chown log directory + chown -R --no-dereference 100:101 /var/log/apache2 + +# Rinetd +#RUN set -eux; \ +# echo "0.0.0.0 8082 minio 9000" >> /etc/rinetd.conf + +#Install uws +#WORKDIR /var/ + +COPY dataserver/. /var/www/zotero/ + +RUN rm -rf /var/www/zotero/include/Zend +COPY Zend /var/www/zotero/include/Zend +COPY docker/dataserver/create-user.sh /var/www/zotero/admin/ +COPY docker/dataserver/config.inc.php /var/www/zotero/include/config/ +COPY docker/dataserver/dbconnect.inc.php /var/www/zotero/include/config/ +COPY docker/dataserver/header.inc.php /var/www/zotero/include/ +COPY docker/dataserver/Storage.inc.php /var/www/zotero/model/ +COPY docker/dataserver/FullText.inc.php /var/www/zotero/model/ +COPY docker/db/init-mysql.sh /var/www/zotero/misc/ +COPY docker/db/db_update.sh /var/www/zotero/misc/ +COPY docker/db/www.sql /var/www/zotero/misc/ +COPY docker/db/shard.sql /var/www/zotero/misc/ + + + +ENV APACHE_RUN_USER=apache +ENV APACHE_RUN_GROUP=apache +ENV APACHE_LOCK_DIR=/var/lock/apache2 +ENV APACHE_PID_FILE=/var/run/apache2/apache2.pid +ENV APACHE_RUN_DIR=/var/run/apache2 +ENV APACHE_LOG_DIR=/var/log/apache2 + +COPY dataserver/. /var/www/zotero/ + +RUN rm -rf /var/www/zotero/include/Zend +COPY Zend /var/www/zotero/include/Zend +COPY docker/dataserver/create-user.sh /var/www/zotero/admin/ +COPY docker/dataserver/config.inc.php /var/www/zotero/include/config/ +COPY docker/dataserver/dbconnect.inc.php /var/www/zotero/include/config/ +COPY docker/dataserver/header.inc.php /var/www/zotero/include/ +COPY docker/dataserver/Storage.inc.php /var/www/zotero/model/ +COPY docker/dataserver/FullText.inc.php /var/www/zotero/model/ +COPY docker/db/init-mysql.sh /var/www/zotero/misc/ +COPY docker/db/db_update.sh /var/www/zotero/misc/ +COPY docker/db/www.sql /var/www/zotero/misc/ +COPY docker/db/shard.sql /var/www/zotero/misc/ + + +# Expose and entrypoint +COPY docker/dataserver/entrypoint.sh / +RUN chmod +x /entrypoint.sh +#VOLUME /var/www/zotero +EXPOSE 80/tcp +#EXPOSE 81/TCP +#EXPOSE 82/TCP +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/ds.Dockerfile.dockerignore b/ds.Dockerfile.dockerignore new file mode 100644 index 00000000..47951a91 --- /dev/null +++ b/ds.Dockerfile.dockerignore @@ -0,0 +1,21 @@ +**/secret.json +**/secret.txt +**/secret.yaml +.env +.git +.github +.vscode/ +bin +build +client +#dataserver +doc +docker/stream-server +docker/miniomc +docker-compose.yml +logs +stream-server +tinymce-clean-server +#Zend +zotprime-k8s + diff --git a/es.Dockerfile b/es.Dockerfile new file mode 100644 index 00000000..d4077afa --- /dev/null +++ b/es.Dockerfile @@ -0,0 +1,7 @@ + +############################ +# elasticsearch image +############################ + +# image: docker.elastic.co/elasticsearch/elasticsearch:5.3.0 +FROM docker.elastic.co/elasticsearch/elasticsearch:8.7.0 \ No newline at end of file diff --git a/es.Dockerfile.dockerignore b/es.Dockerfile.dockerignore new file mode 100644 index 00000000..44d4ecf7 --- /dev/null +++ b/es.Dockerfile.dockerignore @@ -0,0 +1,20 @@ +**/secret.json +**/secret.txt +**/secret.yaml +.git +.github +.env +.vscode/ +bin +build +client +dataserver +doc +docker +docker-compose.yml +logs +stream-server +tinymce-clean-server +Zend +zotprime-k8s + diff --git a/htdocs/.htaccess b/htdocs/.htaccess deleted file mode 100644 index d8052dd3..00000000 --- a/htdocs/.htaccess +++ /dev/null @@ -1,25 +0,0 @@ -# If on a testing site, deny by default unless IP is allowed -SetEnvIf Host "apidev" ACCESS_CONTROL -SetEnvIf Host "syncdev" ACCESS_CONTROL -####### Local -SetEnvIf X-Forwarded-For "192.168.1.|" !ACCESS_CONTROL -order deny,allow -deny from env=ACCESS_CONTROL - -php_value include_path "../include" -php_value auto_prepend_file "header.inc.php" -php_value auto_append_file "footer.inc.php" - -php_value memory_limit 500M - -#php_value xdebug.show_local_vars 1 -#php_value xdebug.profiler_enable 1 -#php_value xdebug.profiler_enable_trigger 1 -#php_value xdebug.profiler_output_dir /tmp/xdebug - -RewriteEngine On - -# If file or directory doesn't exist, pass to director for MVC redirections -RewriteCond %{SCRIPT_FILENAME} !-f -RewriteCond %{SCRIPT_FILENAME} !-d -RewriteRule .* index.php [L] diff --git a/htdocs/errors/404.php b/htdocs/errors/404.php deleted file mode 100644 index 63378c5e..00000000 --- a/htdocs/errors/404.php +++ /dev/null @@ -1,2 +0,0 @@ -

Not Found

-

The page you requested could not be found.

diff --git a/htdocs/index.php b/htdocs/index.php deleted file mode 100644 index b17d9908..00000000 --- a/htdocs/index.php +++ /dev/null @@ -1,64 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -$params = require('config/routes.inc.php'); - -if (!$params || !isset($params['controller']) || $params['controller'] == 404) { - header("HTTP/1.0 404 Not Found"); - include('errors/404.php'); - return; -} - -// Parse variables from router -$controllerName = ucwords($params['controller']); -$action = !empty($params['action']) ? $params['action'] : lcfirst($controllerName); -$directory = !empty($params['directory']) ? $params['directory'] . '/' : ""; -$extra = !empty($params['extra']) ? $params['extra'] : array(); - -// Attempt to load controller -$controllerFile = Z_ENV_CONTROLLER_PATH . $directory . $controllerName . 'Controller.php'; -Z_Core::debug($_SERVER['REQUEST_METHOD'] . " to " . Z_ENV_SELF); -Z_Core::debug("Controller is $controllerFile"); - -if (file_exists($controllerFile)) { - require('mvc/Controller.inc.php'); - require($controllerFile); - $controllerClass = $controllerName . 'Controller'; - $controller = new $controllerClass($controllerName, $action, $params); - $controller->init($extra); - - if (method_exists($controllerClass, $action)) { - call_user_func(array($controller, $action)); - Z_Core::exitClean(); - } - else { - throw new Exception("Action '$action' not found in $controllerFile"); - } -} - -// If controller not found, load error document -header("HTTP/1.0 404 Not Found"); -include('errors/404.php'); diff --git a/htdocs/license.html b/htdocs/license.html deleted file mode 100644 index aeebb6d2..00000000 --- a/htdocs/license.html +++ /dev/null @@ -1,14 +0,0 @@ -

Zotero Data Server
-Copyright © 2010 Center for History and New Media
-George Mason University, Fairfax, Virginia, USA
-http://zotero.org

- -

The Center for History and New Media distributes the Zotero Data Server source code -under the GNU Affero General Public License, version 3 (AGPLv3).

- -

The Zotero name is a registered trademark of George Mason University. -See http://zotero.org/trademark for more information.

- -

Third-party copyright in this distribution is noted where applicable.

- -

All rights not expressly granted are reserved.

diff --git a/htdocs/license.txt b/htdocs/license.txt deleted file mode 100644 index 491c0562..00000000 --- a/htdocs/license.txt +++ /dev/null @@ -1,14 +0,0 @@ -Zotero Data Server -Copyright © 2010 Center for History and New Media -George Mason University, Fairfax, Virginia, USA -http://zotero.org - -The Center for History and New Media distributes the Zotero Data Server source code -under the GNU Affero General Public License, version 3 (AGPLv3). - -The Zotero name is a registered trademark of George Mason University. -See http://zotero.org/trademark for more information. - -Third-party copyright in this distribution is noted where applicable. - -All rights not expressly granted are reserved. diff --git a/include/Base64.inc.php b/include/Base64.inc.php deleted file mode 100644 index 8884ec26..00000000 --- a/include/Base64.inc.php +++ /dev/null @@ -1,131 +0,0 @@ -> 2; - $enc2 = (($chr1 & 3) << 4) | ($chr2 >> 4); - $enc3 = (($chr2 & 15) << 2) | ($chr3 >> 6); - $enc4 = $chr3 & 63; - - if (is_nan($chr2)) { - $enc3 = $enc4 = 64; - } else if (is_nan($chr3)) { - $enc4 = 64; - } - - $output = $output . - Z_Unicode::charAt(self::$keyStr, $enc1) . Z_Unicode::charAt(self::$keyStr, $enc2) . - Z_Unicode::charAt(self::$keyStr, $enc3) . Z_Unicode::charAt(self::$keyStr, $enc4); - - } - - return $output; - } - - // public method for decoding - public static function decode($input) { - $output = ""; - $chr1 = $chr2 = $chr3 = $enc1 = $enc2 = $enc3 = $enc4 = ""; - $i = 0; - - $input = preg_replace('/[^A-Za-z0-9\+\/\=]/', "", $input); - - while ($i < mb_strlen($input)) { - $enc1 = strpos(self::$keyStr, $input[$i++]); - $enc2 = strpos(self::$keyStr, $input[$i++]); - $enc3 = strpos(self::$keyStr, $input[$i++]); - $enc4 = strpos(self::$keyStr, $input[$i++]); - - $chr1 = ($enc1 << 2) | ($enc2 >> 4); - $chr2 = (($enc2 & 15) << 4) | ($enc3 >> 2); - $chr3 = (($enc3 & 3) << 6) | $enc4; - - $output = $output . Z_Unicode::unichr($chr1); - - if ($enc3 != 64) { - $output = $output . Z_Unicode::unichr($chr2); - } - if ($enc4 != 64) { - $output = $output . Z_Unicode::unichr($chr3); - } - } - - $output = self::utf8_decode($output); - - return $output; - } - - // private method for UTF-8 encoding - private static function utf8_encode($string) { - $string = preg_replace('/\r\n/', "\n", $string); - $utftext = ""; - - for ($n = 0; $n < strlen($string); $n++) { - - $c = Z_Unicode::charCodeAt($string, $n); - - if ($c < 128) { - $utftext .= Z_Unicode::fromCharCode($c); - } - else if(($c > 127) && ($c < 2048)) { - $utftext .= Z_Unicode::fromCharCode(($c >> 6) | 192); - $utftext .= Z_Unicode::fromCharCode(($c & 63) | 128); - } - else { - $utftext .= Z_Unicode::fromCharCode(($c >> 12) | 224); - $utftext .= Z_Unicode::fromCharCode((($c >> 6) & 63) | 128); - $utftext .= Z_Unicode::fromCharCode(($c & 63) | 128); - } - - } - - return $utftext; - } - - // private method for UTF-8 decoding - private static function utf8_decode($utftext) { - $string = ""; - $i = 0; - $c = $c1 = $c2 = 0; - - while ( $i < mb_strlen($utftext) ) { - - $c = Z_Unicode::charCodeAt($utftext, $i); - - if ($c < 128) { - $string .= Z_Unicode::fromCharCode($c); - $i++; - } - else if(($c > 191) && ($c < 224)) { - $c2 = Z_Unicode::charCodeAt($utftext, $i+1); - $string .= Z_Unicode::fromCharCode((($c & 31) << 6) | ($c2 & 63)); - $i += 2; - } - else { - $c2 = Z_Unicode::charCodeAt($utftext, $i+1); - $c3 = Z_Unicode::charCodeAt($utftext, $i+2); - $string .= Z_Unicode::fromCharCode((($c & 15) << 12) | (($c2 & 63) << 6) | ($c3 & 63)); - $i += 3; - } - } - - return $string; - } -} diff --git a/include/Core.inc.php b/include/Core.inc.php deleted file mode 100644 index b0104f11..00000000 --- a/include/Core.inc.php +++ /dev/null @@ -1,121 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Z_Core { - // Set in header.php - public static $AWS = null; // AWS-SDK - public static $MC = null; // Memcached - public static $Elastica = null; // ElasticSearch client - - // Set in config.inc.php - public static $debug = false; - - public static function debug($str, $level=false) { - if (self::$debug) { - error_log($str); - } - //Z_Log::log(Z_CONFIG::$LOG_TARGET_DEFAULT, $str); - } - - - public static function isCommandLine() { - return php_sapi_name() == 'cli'; - } - - public static function logError($message) { - Z_Log::log(Z_CONFIG::$LOG_TARGET_DEFAULT, $message); - } - - /** - * Log errors and save an error report to disk with optional additional text - */ - public static function reportErrors($errors, $text = '') { - try { - $write = !Z_ENV_TESTING_SITE && !empty(Z_CONFIG::$ERROR_PATH); - $id = substr(md5(uniqid(rand(), true)), 0, 10); - $logStr = ""; - $fileStr = date("D M j G:i:s T Y") . "\n"; - if (!empty($_SERVER)) { - $fileStr .= "IP address: " . $_SERVER['REMOTE_ADDR'] . "\n"; - if (isset($_SERVER['HTTP_X_ZOTERO_VERSION'])) { - $str = "Version: " . $_SERVER['HTTP_X_ZOTERO_VERSION']; - $logStr .= $str . " "; - $fileStr .= $str . "\n"; - } - $str = $_SERVER['REQUEST_METHOD'] . " " . self::getCleanRequestURI(); - $logStr .= $str; - $fileStr .= $str . "\n\n"; - } - foreach ($errors as $e) { - // Log each error as a separate line - Z_Core::logError( - $e->getMessage() - . ' in ' . $e->getFile() . ':' . $e->getLine() - . ($logStr ? ' (' . $logStr . ')' : '') - . ($write ? " ($id)" : '') - ); - - // And add to report - $fileStr .= $e . "\n\n"; - } - if ($text) { - $fileStr .= $text . "\n"; - } - - if ($write) { - file_put_contents(Z_CONFIG::$ERROR_PATH . $id, $fileStr); - } - } - catch (Exception $e) { - self::logError($e); - } - } - - public static function getCleanRequestURI() { - return preg_replace('/key=[A-Za-z0-9]+/', 'key=xxx', $_SERVER['REQUEST_URI']); - } - - public static function getBacktrace() { - ob_start(); - debug_print_backtrace(); - return ob_get_clean(); - } - - public static function exitClean() { - include('footer.inc.php'); - exit; - } - - /** - * Return true according to a given probability - * - * @param int $x Will return true every $x times on average - * @return bool On average, TRUE every $x times the function is called - **/ - public static function probability($x) { - return rand(1,$x) == rand(1,$x); - } -} diff --git a/include/DB.inc.php b/include/DB.inc.php deleted file mode 100644 index 4071eb48..00000000 --- a/include/DB.inc.php +++ /dev/null @@ -1,1438 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -require_once('Zend/Db/Profiler.php'); -require_once('Zend/Db/Adapter/Mysqli.php'); -require_once('Zend/Db/Statement/Mysqli.php'); -require_once('Zend/Db/Statement/Mysqli/Exception.php'); - -/** -* -* Singleton class for DB access -* -**/ -class Zotero_DB { - public static $queryStats; - - protected static $instances; - - protected $connections = []; - protected $replicaConnections = []; - private $profilerEnabled = false; - - private $readOnly = false; - private $readOnlyShards = []; - private $transactionLevel = 0; - private $transactionTimestamp; - private $transactionTimestampMS; - private $transactionTimestampUnix; - private $transactionConnections = []; - private $transactionRollback = false; - - private $testFailureCounts = []; - - private $callbacks = array( - 'begin' => array(), - 'commit' => array(), - 'rollback' => array() - ); - - protected $db = 'master'; - - protected function __construct() { - // Set up main link - $auth = Zotero_DBConnectAuth($this->db); - $this->connections[0] = $this->getConnection(0, $auth); - } - - - protected function getShardConnection($shardID, array $options = []) { - if (!is_numeric($shardID)) { - throw new Exception('$shardID must be an integer'); - } - - $isWriteQuery = !empty($options['isWriteQuery']); - $lastLinkFailed = !empty($options['lastLinkFailed']); - $writeInReadMode = !empty($options['writeInReadMode']); - - $linkID = $shardID; - - if ($this->isReadOnly($shardID) && $isWriteQuery && !$writeInReadMode) { - throw new Exception("Cannot get link for writing to shard $shardID in read-only mode"); - } - - // For read-only mode and read queries, use a cached link if available. Since this is - // done before checking the latest shard info, it's possible for subsequent read queries - // in a request to go through even if the shard was since disabled, but that's generally - // not a big deal, and new requests will check the shard info again and throw. - // - // Read-only mode - if ($this->isReadOnly($shardID) && !$writeInReadMode) { - // Use a cached replica link if available. - if (isset($this->replicaConnections[$linkID])) { - // If the last link failed, try the next one. If no more, that's fatal. - if ($lastLinkFailed) { - $lastHost = $this->replicaConnections[$linkID][0]->host; - if (sizeOf($this->replicaConnections[$linkID]) == 1) { - throw new Exception("Read failed from replica $lastHost -- no more replica connections", Z_ERROR_SHARD_UNAVAILABLE); - } - error_log("WARNING: Read failed from replica $lastHost -- retrying on another replica"); - array_shift($this->replicaConnections[$linkID]); - } - //error_log($this->replicaConnections[$linkID][0]->link->getConnection()->host_info); - return $this->replicaConnections[$linkID][0]; - } - } - // Read queries in read/write mode - // - // Use a cached link if available - else if (!$isWriteQuery && isset($this->connections[$linkID])) { - //error_log($this->connections[$linkID]->link->getConnection()->host_info); - return $this->connections[$linkID]; - } - - // If not a shard, get info from config file - if ($shardID === 0) { - $shardInfo = Zotero_DBConnectAuth($this->db); - // Use DB name as shardHostID - $shardInfo['shardHostID'] = $this->db; - } - else { - $shardInfo = Zotero_Shards::getShardInfo($shardID); - } - if (!$shardInfo) { - throw new Exception("Invalid shard $shardID"); - } - - if ($shardInfo['state'] == 'down') { - throw new Exception("Shard $shardID is down", Z_ERROR_SHARD_UNAVAILABLE); - } - else if ($shardInfo['state'] == 'readonly') { - if ($isWriteQuery && get_called_class() != 'Zotero_Admin_DB') { - throw new Exception("Cannot write to read-only shard $shardID", Z_ERROR_SHARD_READ_ONLY); - } - } - - if ($this->isReadOnly($shardID) && !$writeInReadMode) { - if (isset($shardInfo['replicas'])) { - $replicas = $shardInfo['replicas']; - } - else { - $replicas = Zotero_Shards::getReplicaInfo($shardInfo['shardHostID']); - } - if ($replicas) { - $writerCacheKey = 'shardHostReplicasWriter_' . $shardInfo['shardHostID']; - $writerAddress = Z_Core::$MC->get($writerCacheKey); - $writerConn = null; - - // Randomize replica order - shuffle($replicas); - foreach ($replicas as $replica) { - $connInfo = $shardInfo; - // TEMP: Remove 'address' - $connInfo['host'] = !empty($replica['host']) ? $replica['host'] : $replica['address']; - $connInfo['port'] = !empty($replica['port']) ? $replica['port'] : 3306; - - $authInfo = $shardID === 0 ? $shardInfo : Zotero_DBConnectAuth('shard'); - $connInfo['user'] = $authInfo['user']; - $connInfo['pass'] = $authInfo['pass']; - $connInfo['charset'] = !empty($authInfo['charset']) ? $authInfo['charset'] : null; - - // Use a lower connection timeout for read replicas - if ($connInfo['host'] != $writerAddress) { - $connInfo['driver_options'] = [ - 'MYSQLI_OPT_CONNECT_TIMEOUT' => 2 - ]; - } - - $conn = $this->getConnection($linkID, $connInfo); - - if ($connInfo['host'] == $writerAddress) { - $writerConn = $conn; - } - else { - $this->replicaConnections[$linkID][] = $conn; - } - } - - // If we know the writer, add it last, so that all read replicas are tried first - if ($writerConn) { - $this->replicaConnections[$linkID][] = $writerConn; - } - // Otherwise, check the randomly sorted first replica to see if it's the writer - else { - try { - $results = $this->replicaConnections[$linkID][0]->link->query( - "SHOW GLOBAL VARIABLES LIKE 'innodb_read_only'" - ); - $row = $results->fetch(Zend_Db::FETCH_ASSOC); - $connReadOnly = $row ? $row['Value'] : false; - // If we found the writer - if ($connReadOnly == "OFF") { - // Store host in memcached so that every request doesn't need to check - // the variables to sort the writer last. - // - // This can probably be increased, because the only consequence of not - // knowing the writer is that a few requests could use the writer - // for reads. - Z_Core::$MC->set( - $writerCacheKey, - $this->replicaConnections[$linkID][0]->host, - 60 - ); - - // If more than one connection, move writer connection to end and - // close it - if (sizeOf($this->replicaConnections[$linkID]) > 1) { - $conn = array_shift($this->replicaConnections[$linkID]); - $this->replicaConnections[$linkID][] = $conn; - $conn->link->closeConnection(); - } - } - } - catch (Exception $e) { - error_log("WARNING: Failed checking replica state: $e"); - } - } - - //error_log($this->replicaConnections[$linkID][0]->link->getConnection()->host_info); - return $this->replicaConnections[$linkID][0]; - } - } - - // Host isn't read-only or down, so write queries can use a cached link if available. - // Otherwise, make a new link. - if (!isset($this->connections[$linkID])) { - // TEMP: Remove 'address' - $shardInfo['host'] = !empty($shardInfo['host']) ? $shardInfo['host'] : $shardInfo['address']; - - if ($linkID) { - $authInfo = Zotero_DBConnectAuth('shard'); - $shardInfo['user'] = $authInfo['user']; - $shardInfo['pass'] = $authInfo['pass']; - $shardInfo['charset'] = !empty($authInfo['charset']) ? $authInfo['charset'] : null; - } - - $this->connections[$linkID] = $this->getConnection($linkID, $shardInfo); - } - //error_log($this->connections[$linkID]->link->getConnection()->host_info); - return $this->connections[$linkID]; - } - - - private function getConnection($shardID, $info) { - $config = [ - 'host' => $info['host'], - 'port' => $info['port'], - 'username' => $info['user'], - 'password' => $info['pass'], - 'dbname' => $info['db'], - 'charset' => !empty($info['charset']) ? $info['charset'] : 'utf8', - 'driver_options' => [ - "MYSQLI_OPT_CONNECT_TIMEOUT" => 5 - ] - ]; - - // For admin, use user/pass from master - if (get_called_class() == 'Zotero_Admin_DB') { - $auth = Zotero_DBConnectAuth($this->db); - $config['username'] = $auth['user']; - $config['password'] = $auth['pass']; - - // Don't time out during long maintenance operations. It's not clear which of these - // are necessary. mysqlnd.net_read_timeout=3600 is also necessary, and as of 7.0.21 - // isn't working via ini_set (even though it's supposed to), so it has to be set with - // php -d in the migration script). - $timeout = 3600; - $info['driver_options'] = [ - 'MYSQL_OPT_READ_TIMEOUT' => $timeout, - 'MYSQL_OPT_WRITE_TIMEOUT' => $timeout - ]; - ini_set('default_socket_timeout', $timeout); - } - - // Apply connection options - if (isset($info['driver_options'])) { - foreach ($info['driver_options'] as $key => $val) { - $config['driver_options'][$key] = $val; - } - } - - $conn = new Zotero_DB_Connection; - $conn->shardID = $shardID; - $conn->host = $info['host']; - - $link = new Zend_Db_Adapter_Mysqli($config); - - // If profile was previously enabled, enable it for this link - if ($this->profilerEnabled) { - $link->getProfiler()->setEnabled(true); - } - $conn->link = $link; - - return $conn; - } - - - /** - * Get an instance of the appropriate class - */ - protected static function getInstance() { - $class = get_called_class(); - - if (empty(self::$instances[$class])) { - self::$instances[$class] = new $class; - } - - return self::$instances[$class]; - } - - - public static function isReadOnly($shardID = null) { - $instance = self::getInstance(); - if (is_numeric($shardID)) { - if (isset($instance->readOnlyShards[$shardID])) { - return $instance->readOnlyShards[$shardID]; - } - } - return $instance->readOnly; - } - - - /** - * Enable or disable read-only mode - */ - public static function readOnly($set, $shards = null) { - $instance = self::getInstance(); - if (isset($shards)) { - if (is_numeric($shards)) { - $shards = [$shards]; - } - foreach ($shards as $shardID) { - $instance->readOnlyShards[$shardID] = !!$set; - } - } - else { - $instance->readOnly = !!$set; - } - } - - - /** - * Start a virtual MySQL transaction or increase the transaction nesting level - * - * If a transaction is already in progress, the nesting level will be incremented by one - * - * Note that this doesn't actually start a transaction. Transactions are started - * lazily on each shard that gets a query while a virtual transaction is open, - * with commits on all affected shards at the end. - * - * This only works with InnoDB tables. - */ - public static function beginTransaction() { - $instance = self::getInstance(); - - $instance->transactionLevel++; - if ($instance->transactionLevel > 1) { - Z_Core::debug("Transaction in progress -- nesting level increased to $instance->transactionLevel"); - return -1; - } - - $instance->transactionTimestamp = null; - $instance->transactionTimestampUnix = null; - - Z_Core::debug("Starting transaction"); - - foreach ($instance->callbacks['begin'] as $callback) { - call_user_func($callback); - } - } - - - /** - * Commit a MySQL transaction or decrease the transaction nesting level - * - * If a transaction is already in progress, the nesting level will be decremented by one - * rather than committing. - * - * This only works with InnoDB tables - */ - public static function commit() { - $instance = self::getInstance(); - - if ($instance->transactionLevel == 0) { - throw new Exception("Transaction not open"); - } - - $instance->transactionLevel--; - if ($instance->transactionLevel) { - Z_Core::debug("Transaction in progress -- nesting level decreased to $instance->transactionLevel"); - return -1; - } - - if ($instance->transactionRollback) { - Z_Core::debug("Rolling back previously flagged transaction"); - self::rollback(); - return; - } - - while ($conn = array_pop($instance->transactionConnections)) { - $instance->commitReal($conn); - } - - foreach ($instance->callbacks['commit'] as $callback) { - call_user_func($callback); - } - } - - - /** - * Rollback MySQL transactions on all shards - * - * This only works with InnoDB tables - */ - public static function rollback($all=false) { - $instance = self::getInstance(); - - if ($instance->transactionLevel == 0) { - if (!$all) { - Z_Core::debug('Transaction not open in Zotero_DB::rollback()'); - } - return; - } - - if ($all) { - $instance->transactionLevel = 1; - self::rollback(); - return; - } - - if ($instance->transactionLevel > 1) { - Z_Core::debug("Flagging nested transaction for rollback"); - $instance->transactionRollback = true; - $instance->transactionLevel--; - return; - } - - while ($conn = array_pop($instance->transactionConnections)) { - $instance->rollBackReal($conn); - } - - $instance->transactionLevel--; - $instance->transactionRollback = false; - - foreach ($instance->callbacks['rollback'] as $callback) { - call_user_func($callback); - } - } - - - public static function addCallback($action, $cb) { - $instance = self::getInstance(); - $instance->callbacks[$action][] = $cb; - } - - - public static function transactionInProgress() { - $instance = self::getInstance(); - return $instance->transactionLevel > 0; - } - - - public static function getTransactionTimestamp() { - $instance = self::getInstance(); - - if ($instance->transactionLevel == 0) { - throw new Exception("Transaction not open"); - } - - if (empty($instance->transactionTimestamp)) { - $instance->transactionTimestamp = Zotero_DB::valueQuery("SELECT NOW()"); - } - - return $instance->transactionTimestamp; - } - - - public static function getTransactionTimestampUnix() { - $instance = self::getInstance(); - - if ($instance->transactionLevel == 0) { - throw new Exception("Transaction not open"); - } - - if (empty($instance->transactionTimestampUnix)) { - $ts = self::getTransactionTimestamp(); - $instance->transactionTimestampUnix = strtotime($ts); - } - - return $instance->transactionTimestampUnix; - } - - - public static function registerTransactionTimestamp($unixTimestamp) { - $instance = self::getInstance(); - - if (!empty($instance->transactionTimestamp)) { - throw new Exception("Transaction timestamp already set"); - } - - $instance->transactionTimestamp = date("Y-m-d H:i:s", $unixTimestamp); - $instance->transactionTimestampUnix = $unixTimestamp; - } - - - /* - * @return Zotero_DBStatement - */ - public static function getStatement($sql, $cache = false, $shardID = 0, array $options = []) { - $instance = self::getInstance(); - - // For testing, simulate an error reading from a replica (disabled by default) - $testFailures = false; - if ($testFailures - && $shardID != 0 - && !empty($options['internalStatement']) - && $instance->isReadOnly($shardID)) { - if (!isset($instance->testFailureCounts[$shardID])) { - $instance->testFailureCounts[$shardID] = 0; - } - $instance->testFailureCounts[$shardID]++; - if ($instance->testFailureCounts[$shardID] == 5) { - error_log("Failing for test!"); - throw new Exception("Fake failure"); - } - } - - $options['isWriteQuery'] = self::isWriteQuery($sql); - $conn = $instance->getShardConnection($shardID, $options); - - if ($cache) { - if (is_bool($cache)) { - $key = md5($sql); - } - // Supplied key - else if (is_string($cache)) { - $key = $cache; - } - else { - throw new Exception("Invalid cache type '$cache'"); - } - } - - // See if statement is already cached for this shard - if ($cache && isset($conn->statements[$key])) { - return $conn->statements[$key]; - } - - $stmt = new Zotero_DB_Statement($conn->link, $sql, $shardID); - - // Cache for future use - if ($cache) { - $conn->statements[$key] = $stmt; - } - - return $stmt; - } - - public static function query($sql, $params=false, $shardID=0, array $options=[]) { - self::logQuery($sql, $params, $shardID); - - $instance = self::getInstance(); - $instance->checkShardTransaction($shardID); - $isWriteQuery = self::isWriteQuery($sql); - $cacheStatement = !isset($options['cache']) || $options['cache'] === true; - $options['internalStatement'] = true; - - if ($params !== false && (is_scalar($params) || is_null($params))) { - $params = array($params); - } - - try { - if (is_array($params)) { - // Replace null parameter placeholders with 'NULL' - for ($i=0, $len=sizeOf($params); $i<$len; $i++) { - if (is_null($params[$i])) { - preg_match_all('/\s*=?\s*\?/', $sql, $matches, PREG_OFFSET_CAPTURE); - if (strpos($matches[0][$i][0], '=') === false) { - preg_match_all('/\?/', $sql, $matches, PREG_OFFSET_CAPTURE); - $repl = 'NULL'; - $sublen = 1; - } - else if (!$isWriteQuery) { - $repl = ' IS NULL'; - $sublen = strlen($matches[0][$i][0]); - } - else { - $repl = '=NULL'; - $sublen = strlen($matches[0][$i][0]); - } - - $subpos = $matches[0][$i][1]; - $sql = substr_replace($sql, $repl, $subpos, $sublen); - - array_splice($params, $i, 1); - $i--; - $len--; - continue; - } - } - - $stmt = self::getStatement($sql, $cacheStatement, $shardID, $options); - $stmt->execute($params); - } - else { - $stmt = self::getStatement($sql, $cacheStatement, $shardID, $options); - $stmt->execute(); - } - - return self::queryFromStatement($stmt); - } - catch (Exception $e) { - // Writes in read mode are allowed to fail - if (!empty($options['writeInReadMode'])) { - $str = self::getErrorString($e, $sql, $params, $shardID); - error_log("WARNING: $str"); - return; - } - - // In read mode, retry automatically if not in a transaction - if ($instance->isReadOnly($shardID) - && !$instance->transactionLevel - && empty($options['lastLinkFailed'])) { - $options['lastLinkFailed'] = true; - return self::query($sql, $params, $shardID, $options); - } - - self::error($e, $sql, $params, $shardID); - } - finally { - if (isset($stmt) && !$cacheStatement) { - $stmt->close(); - } - } - } - - - public static function queryFromStatement(Zotero_DB_Statement $stmt, $params=false) { - try { - // Execute statement if not coming from self::query() - if ($params) { - self::logQuery($stmt->sql, $params, $stmt->shardID); - - // If this is a write query, make sure shard is writeable - if ($stmt->isWriteQuery && $stmt->shardID && !Zotero_Shards::shardIsWriteable($stmt->shardID)) { - throw new Exception("Cannot write to read-only shard $stmt->shardID", Z_ERROR_SHARD_READ_ONLY); - } - - $instance = self::getInstance(); - $instance->checkShardTransaction($stmt->shardID); - - if (is_scalar($params)) { - $params = array($params); - } - $stmt->execute($params); - } - - $stmt->setFetchMode(Zend_Db::FETCH_ASSOC); - - $mystmt = $stmt->getDriverStatement(); - - // Not a read statement - if (!$mystmt->field_count) { - // Determine the type of query using first word - preg_match('/^[^\s\(]*/', $stmt->sql, $matches); - $queryMethod = strtolower($matches[0]); - - if ($queryMethod == "update" || $queryMethod == "delete") { - return $stmt->rowCount(); - } - else if ($queryMethod == "insert") { - $insertID = (int) $stmt->link->lastInsertID(); - if ($insertID) { - return $insertID; - } - $affectedRows = $stmt->rowCount(); - if (!$affectedRows) { - return false; - } - return $affectedRows; - } - return true; - } - - // Cast integers - $intFieldNames = self::getIntegerColumns($mystmt); - $results = array(); - while ($row = $stmt->fetch()) { - if ($intFieldNames) { - foreach ($intFieldNames as $name) { - if (is_null($row[$name])) { - $row[$name] = null; - } - // 32-bit hack: cast only numbers shorter than 10 characters as ints - else if (strlen($row[$name]) < 10) { - $row[$name] = (int) $row[$name]; - } - } - } - $results[] = $row; - } - } - catch (Exception $e) { - self::error($e, $stmt->sql, $params, $stmt->shardID); - } - - return $results; - } - - - public static function columnQuery($sql, $params = false, $shardID = 0, array $options = []) { - self::logQuery($sql, $params, $shardID); - - $instance = self::getInstance(); - $instance->checkShardTransaction($shardID); - if (self::isWriteQuery($sql)) { - throw new Exception("Can't use columnQuery() for write query -- use query()"); - } - $cacheStatement = !isset($options['cache']) || $options['cache'] === true; - $options['internalStatement'] = true; - - // TODO: Use instance->link->fetchCol once it supports type casting - - if ($params && is_scalar($params)) { - $params = array($params); - } - - try { - $stmt = self::getStatement($sql, $cacheStatement, $shardID, $options); - if ($params) { - $stmt->execute($params); - } - else { - $stmt->execute(); - } - - return self::columnQueryFromStatement($stmt); - } - catch (Exception $e) { - // In read mode, retry connection errors automatically - if (self::isConnectionError($e) - && $instance->isReadOnly($shardID) && empty($options['lastLinkFailed'])) { - $options['lastLinkFailed'] = true; - return self::columnQuery($sql, $params, $shardID, $options); - } - - self::error($e, $sql, $params, $shardID); - } - finally { - if (isset($stmt) && !$cacheStatement) { - $stmt->close(); - } - } - } - - - public static function columnQueryFromStatement(Zotero_DB_Statement $stmt, $params=false) { - try { - // Execute statement if not coming from self::query() - if ($params) { - self::logQuery($stmt->sql, $params, $stmt->shardID); - - $instance = self::getInstance(); - $instance->checkShardTransaction($stmt->shardID); - - if (is_scalar($params)) { - $params = array($params); - } - $stmt->execute($params); - } - - $stmt->setFetchMode(Zend_Db::FETCH_NUM); - - $vals = array(); - while ($val = $stmt->fetchColumn()) { - $vals[] = $val; - } - if (!$vals) { - return false; - } - - // Cast integers - $mystmt = $stmt->getDriverStatement(); - if (self::getIntegerColumns($mystmt)) { - $cast = function ($val) { - if (is_null($val)) { - return null; - } - // 32-bit hack: cast only numbers shorter than 10 characters as ints - return (strlen($val) < 10) ? (int) $val : $val; - }; - return array_map($cast, $vals); - } - - return $vals; - } - catch (Exception $e) { - self::error($e, $stmt->sql, $params, $stmt->shardID); - } - } - - - public static function rowQuery($sql, $params = false, $shardID = 0, array $options = []) { - self::logQuery($sql, $params, $shardID); - - $instance = self::getInstance(); - $instance->checkShardTransaction($shardID); - if (self::isWriteQuery($sql)) { - throw new Exception("Can't use rowQuery() for write query -- use query()"); - } - $cacheStatement = !isset($options['cache']) || $options['cache'] === true; - $options['internalStatement'] = true; - - if ($params !== false && (is_scalar($params) || is_null($params))) { - $params = array($params); - } - - try { - $stmt = self::getStatement($sql, $cacheStatement, $shardID, $options); - if ($params) { - $stmt->execute($params); - } - else { - $stmt->execute(); - } - - return self::rowQueryFromStatement($stmt); - } - catch (Exception $e) { - // In read mode, retry automatically - if ($instance->isReadOnly($shardID) && empty($options['lastLinkFailed'])) { - $options['lastLinkFailed'] = true; - return self::rowQuery($sql, $params, $shardID, $options); - } - - self::error($e, $sql, $params, $shardID); - } - finally { - if (isset($stmt) && !$cacheStatement) { - $stmt->close(); - } - } - } - - - public static function rowQueryFromStatement(Zotero_DB_Statement $stmt, $params=false) { - try { - // Execute statement if not coming from self::query() - if ($params) { - self::logQuery($stmt->sql, $params, $stmt->shardID); - - $instance = self::getInstance(); - $instance->checkShardTransaction($stmt->shardID); - - if (is_scalar($params)) { - $params = array($params); - } - $stmt->execute($params); - } - - $stmt->setFetchMode(Zend_Db::FETCH_ASSOC); - $row = $stmt->fetch(); - if (!$row) { - return false; - } - - // Cast integers - $mystmt = $stmt->getDriverStatement(); - $intFieldNames = self::getIntegerColumns($mystmt); - if ($intFieldNames) { - foreach ($intFieldNames as $name) { - if (is_null($row[$name])) { - $row[$name] = null; - } - // 32-bit hack: cast only numbers shorter than 10 characters as ints - else if (strlen($row[$name]) < 10) { - $row[$name] = (int) $row[$name]; - } - } - } - return $row; - } - catch (Exception $e) { - self::error($e, $stmt->sql, $params, $stmt->shardID); - } - } - - - public static function valueQuery($sql, $params = false, $shardID = 0, array $options = []) { - self::logQuery($sql, $params, $shardID); - - $instance = self::getInstance(); - $instance->checkShardTransaction($shardID); - if (self::isWriteQuery($sql)) { - throw new Exception("Can't use valueQuery() for write query -- use query()"); - } - $cacheStatement = !isset($options['cache']) || $options['cache'] === true; - $options['internalStatement'] = true; - - if ($params !== false && (is_scalar($params) || is_null($params))) { - $params = array($params); - } - - try { - $stmt = self::getStatement($sql, $cacheStatement, $shardID, $options); - if ($params) { - $stmt->execute($params); - } - else { - $stmt->execute(); - } - return self::valueQueryFromStatement($stmt); - } - catch (Exception $e) { - // In read mode, retry automatically - if ($instance->isReadOnly($shardID) && empty($options['lastLinkFailed'])) { - $options['lastLinkFailed'] = true; - return self::valueQuery($sql, $params, $shardID, $options); - } - - self::error($e, $sql, $params, $shardID); - } - finally { - if (isset($stmt) && !$cacheStatement) { - $stmt->close(); - } - } - } - - - public static function valueQueryFromStatement(Zotero_DB_Statement $stmt, $params=false) { - try { - // Execute statement if not coming from self::valueQuery() - if ($params) { - self::logQuery($stmt->sql, $params, $stmt->shardID); - - $instance = self::getInstance(); - $instance->checkShardTransaction($stmt->shardID); - - if (is_scalar($params)) { - $params = array($params); - } - $stmt->execute($params); - } - - $stmt->setFetchMode(Zend_Db::FETCH_NUM); - $row = $stmt->fetch(); - if (!$row) { - return false; - } - - $mystmt = $stmt->getDriverStatement(); - - return (self::getIntegerColumns($mystmt) && strlen($row[0]) < 10) ? (is_null($row[0]) ? null : (int) $row[0]) : $row[0]; - } - catch (Exception $e) { - self::error($e, $stmt->sql, $params, $stmt->shardID); - } - } - - - public static function bulkInsert($sql, $sets, $maxInsertGroups, $firstVal = false, $shardID = 0, array $options = []) { - $origInsertSQL = $sql; - $insertSQL = $origInsertSQL; - $insertParams = array(); - $insertCounter = 0; - $options['internalStatement'] = true; - - if (!$sets) { - return; - } - - $paramsPerGroup = sizeOf($sets[0]); - if ($firstVal) { - $paramsPerGroup++; - } - $placeholderStr = "(" . implode(",", array_fill(0, $paramsPerGroup, "?")) . "),"; - - foreach ($sets as $set) { - if (is_scalar($set)) { - $set = array($set); - } - - if ($insertCounter < $maxInsertGroups) { - $insertSQL .= $placeholderStr; - $insertParams = array_merge( - $insertParams, - $firstVal === false ? $set : array_merge(array($firstVal), $set) - ); - } - - if ($insertCounter == $maxInsertGroups - 1) { - $insertSQL = substr($insertSQL, 0, -1); - $stmt = self::getStatement($insertSQL, true, $shardID, $options); - self::queryFromStatement($stmt, $insertParams); - $insertSQL = $origInsertSQL; - $insertParams = array(); - $insertCounter = -1; - } - - $insertCounter++; - } - - if ($insertCounter > 0 && $insertCounter < $maxInsertGroups) { - $insertSQL = substr($insertSQL, 0, -1); - $stmt = self::getStatement($insertSQL, true, $shardID, $options); - self::queryFromStatement($stmt, $insertParams); - } - } - - -/* // Checks the existence of a table in DB - public static function table_exists($table) { - $instance = self::getInstance(); - return $instance->link->tableExists($table); - } - - - // List fields in table - public static function list_fields($table, $exclude=array()) { - $instance = self::getInstance(); - - if (is_string($exclude)) { // allow for single excludes to be passed as strings - $exclude = array($exclude); - } - - $result = $instance->direct_query('SHOW COLUMNS FROM ' . $table); - while ($row = mysqli_fetch_row($result)) { - $field = $row[0]; - - // Check for excluded columns - if ($exclude) { - if (!in_array($field, $exclude)) { - $fields[] = $field; - } - } - else { - $fields[] = $field; - } - } - return $fields; - } - - - public static function has_field($table,$field) { - $instance = self::getInstance(); - - if (isset($GLOBALS['fieldcheck'][$table][$field])) { - return $GLOBALS['fieldcheck'][$table][$field]; - } - $fields = $instance->list_fields($table); - if (is_array($fields) && in_array($field,$fields)) { - return true; - } - return false; - } -*/ - - /** - * Get the possible enum values for a column - * - * @param string $table DB table - * @param string $field Enum column - * @return array Array of possible values - **/ -/* public static function enum_values($table, $field) { - $instance = self::getInstance(); - - $result = $instance->direct_query("SHOW COLUMNS FROM `$table` LIKE '$field'"); - if (mysqli_num_rows($result)>0) { - $row = mysqli_fetch_row($result); - $options = explode("','", preg_replace("/(enum|set)\('(.+?)'\)/","\\2", $row[1])); - } - else { - $options=array(); - } - return $options; - } -*/ - - protected function checkShardTransaction($shardID) { - if (!$this->transactionLevel) { - return; - } - - // Start a transaction for this shard if necessary - $conn = $this->getShardConnection($shardID); - if (!$conn->transactionStarted) { - Z_Core::debug("Beginning transaction on shard $shardID"); - $conn->link->beginTransaction(); - $conn->transactionStarted = true; - $this->transactionConnections[] = $conn; - } - } - - private function commitReal(Zotero_DB_Connection $conn) { - Z_Core::debug("Committing transaction on shard $conn->shardID"); - $conn->link->commit(); - $conn->transactionStarted = false; - } - - private function rollbackReal(Zotero_DB_Connection $conn) { - Z_Core::debug("Rolling back transaction on shard $conn->shardID"); - $conn->link->rollBack(); - $conn->transactionStarted = false; - } - - - /** - * Determine the type of query using first word - */ - public static function isWriteQuery($sql) { - preg_match('/^\(*([^\s\(]+)/', $sql, $matches); - $command = strtoupper($matches[1]); - switch ($command) { - case 'SELECT': - case 'SHOW': - return false; - } - return true; - } - - - protected static function getIntegerColumns(mysqli_stmt $stmt) { - if (!$stmt->field_count) { - return false; - } - $result = $stmt->result_metadata(); - $fieldInfo = mysqli_fetch_fields($result); - $intFieldNames = array(); - for ($i=0, $len=sizeOf($fieldInfo); $i<$len; $i++) { - switch ($fieldInfo[$i]->type) { - // From http://us2.php.net/manual/en/mysqli-result.fetch-field-direct.php - case 1: - case 2: - case 3: - case 8: - case 9: - $intFieldNames[] = $fieldInfo[$i]->name; - break; - } - } - return $intFieldNames; - } - - - /** - * Return the SQL command used in the query - */ - protected static function getQueryCommand($sql) { - preg_match('/^[^\s\(]*/', $sql, $matches); - return $matches[0]; - } - - - protected static function logQuery($sql, $params, $shardID) { - Z_Core::debug($sql - . ($params ? " (" . (is_scalar($params) ? $params : implode(",", $params)) . ") " - . "(shard: $shardID)" : "")); - } - - - public static function profileStart() { - $instance = self::getInstance(); - $instance->profilerEnabled = true; - foreach (array_merge($instance->connections, $instance->replicaConnections) as $conn) { - $profiler = $conn->link->getProfiler(); - $profiler->setEnabled(true); - } - } - - public static function profileEnd($id="", $appendRandomID=true) { - $instance = self::getInstance(); - $instance->profilerEnabled = false; - - $str = ""; - $first = true; - // TODO: Support replica connections - foreach ($instance->connections as $shardID => $conn) { - if ($first) { - $str .= "======================================================================\n\n"; - $first = false; - } - else { - $str .= "----------------------------------------------------------------------\n\n"; - } - $str .= "Shard: $shardID\n\n"; - $profiler = $conn->link->getProfiler(); - $str .= self::getReportFromProfiler($profiler); - $profiler->setEnabled(false); - } - - foreach ($instance->replicaConnections as $shardID => $conn) { - if ($first) { - $str .= "======================================================================\n\n"; - $first = false; - } - else { - $str .= "----------------------------------------------------------------------\n\n"; - } - $str .= "Shard: $shardID (replica)\n\n"; - $profiler = $conn[0]->link->getProfiler(); - $str .= self::getReportFromProfiler($profiler); - $profiler->setEnabled(false); - } - - if ($str) { - if ($appendRandomID) { - if ($id) $id .= "_"; - $id .= substr(md5(uniqid(rand(), true)), 0, 10); - } - file_put_contents("/tmp/profile" . ($id ? "_" . $id : ""), $str); - } - } - - - private static function getReportFromProfiler($profiler) { - $totalTime = $profiler->getTotalElapsedSecs(); - $queryCount = $profiler->getTotalNumQueries(); - $longestTime = 0; - $longestQuery = null; - - if (!$queryCount) { - return ""; - } - - ob_start(); - - $queries = []; - - $profiles = $profiler->getQueryProfiles(); - if ($profiles) { - foreach ($profiles as $query) { - $sql = str_replace("\t", "", str_replace("\n", " ", $query->getQuery())); - $hash = md5($sql); - if (isset($queries[$hash])) { - $queries[$hash]['count']++; - $queries[$hash]['time'] += $query->getElapsedSecs(); - } - else { - $queries[$hash]['sql'] = $sql; - $queries[$hash]['count'] = 1; - $queries[$hash]['time'] = $query->getElapsedSecs(); - } - if ($query->getElapsedSecs() > $longestTime) { - $longestTime = $query->getElapsedSecs(); - $longestQuery = $query->getQuery(); - } - } - } - - foreach($queries as &$query) { - //$query['avg'] = $query['time'] / $query['count']; - } - - usort($queries, function ($a, $b) { - if ($a['time'] == $b['time']) { - return 0; - } - return ($a['time'] < $b['time']) ? -1 : 1; - }); - - var_dump($queries); - - echo 'Executed ' . $queryCount . ' queries in ' . $totalTime . ' seconds' . "\n"; - echo 'Average query length: ' . ($queryCount ? ($totalTime / $queryCount) : "N/A") . ' seconds' . "\n"; - echo 'Queries per second: ' . ($totalTime ? ($queryCount / $totalTime) : "N/A") . "\n"; - echo 'Longest query length: ' . $longestTime . "\n"; - echo "Longest query: " . $longestQuery . "\n\n"; - - return ob_get_clean(); - } - - - public static function isConnectionError(Exception $e) { - $codes = [ - 1040, // Too many connections - 1205, // Lock wait timeout exceeded; try restarting transaction - 1213, // Deadlock found when trying to get lock; try restarting transaction - 2003, // Can't connect to MySQL server - 2006 // MySQL server has gone away - ]; - if (in_array($e->getCode(), $codes)) { - return true; - } - - $messages = [ - "Connection refused", - "Connection timed out" - ]; - $msg = $e->getMessage(); - foreach ($messages as $message) { - if (strpos($msg, $message) !== false) { - return true; - } - } - return false; - } - - - public static function error(Exception $e, $sql, $params=array(), $shardID=0) { - $str = self::getErrorString($e, $sql, $params, $shardID); - - if (strpos($e->getMessage(), "Can't connect to MySQL server") !== false) { - throw new Exception($str, Z_ERROR_SHARD_UNAVAILABLE); - } - - throw new Exception($str, $e->getCode()); - } - - - private static function getErrorString(Exception $e, $sql, $params = [], $shardID = 0) { - $error = $e->getMessage(); - $paramsArray = Z_Array::array2string($params); - - $str = "$error\n\n" - . "Shard: $shardID\n\n" - . "Query:\n$sql\n\n" - . "Params:\n$paramsArray\n\n"; - - if (function_exists('xdebug_get_function_stack')) { - $str .= Z_Array::array2string(xdebug_get_function_stack()); - } - - return $str; - } - - - public static function close($shardID=0) { - $instance = self::getInstance(); - $conn = $instance->getShardConnection($shardID); - // Remove prepared statements for this connection - $conn->statements = []; - $conn->link->closeConnection(); - } -} - - -// -// TODO: Handle failover here instead of in calling code -// -class Zotero_ID_DB_1 extends Zotero_DB { - protected $db = 'id1'; - - protected function __construct() { - parent::__construct(); - } -} - - -class Zotero_ID_DB_2 extends Zotero_DB { - protected $db = 'id2'; - - protected function __construct() { - parent::__construct(); - } -} - - -class Zotero_WWW_DB_1 extends Zotero_DB { - protected $db = 'www1'; - - protected function __construct() { - parent::__construct(); - } -} - - -class Zotero_WWW_DB_2 extends Zotero_DB { - protected $db = 'www2'; - - protected function __construct() { - parent::__construct(); - } -} - - -class Zotero_Cache_DB extends Zotero_DB { - protected $db = 'cache'; - - protected function __construct() { - parent::__construct(); - } -} - - -class Zotero_Admin_DB extends Zotero_DB { - protected $db = 'admin'; - - protected function __construct() { - parent::__construct(); - } -} - - -class Zotero_DB_Connection { - public $shardID; - public $host; - public $link; - public $statements = []; - public $transactionStarted = false; -} - - -class Zotero_DB_Statement extends Zend_Db_Statement_Mysqli { - private $link; - private $sql; - private $shardID; - private $isWriteQuery; - - public function __construct($link, $sql, $shardID=0) { - try { - parent::__construct($link, $sql); - } - catch (Exception $e) { - Zotero_DB::error($e, $sql, array(), $shardID); - } - $this->link = $link; - $this->sql = $sql; - $this->shardID = $shardID; - - $this->isWriteQuery = Zotero_DB::isWriteQuery($sql); - } - - public function __get($name) { - switch ($name) { - case 'link': - case 'sql': - case 'shardID': - case 'isWriteQuery': - return $this->$name; - } - trigger_error("Undefined property '$name' in __get()", E_USER_NOTICE); - return null; - } -} diff --git a/include/Elastica b/include/Elastica deleted file mode 160000 index 66e2ad62..00000000 --- a/include/Elastica +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 66e2ad6264455895e0506b84df03e7189f5f7a71 diff --git a/include/HTTPException.inc.php b/include/HTTPException.inc.php deleted file mode 100644 index a7114491..00000000 --- a/include/HTTPException.inc.php +++ /dev/null @@ -1,10 +0,0 @@ - 600) { - error_log("Invalid HTTP response code $code creating HTTPException -- using 500"); - $code = 500; - } - parent::__construct($message, $code, $previous); - } -} diff --git a/include/IPAddress.inc.php b/include/IPAddress.inc.php deleted file mode 100644 index 6d3cfdcb..00000000 --- a/include/IPAddress.inc.php +++ /dev/null @@ -1,87 +0,0 @@ - diff --git a/include/Memcached.inc.php b/include/Memcached.inc.php deleted file mode 100644 index b2770484..00000000 --- a/include/Memcached.inc.php +++ /dev/null @@ -1,307 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -// Client subclass to add prefix to all keys and add some other behaviors -class Z_MemcachedClientLocal { - public $requestTime = 0.0; - - private $client; - private $disabled; - private $queuing = false; - private $queue = array(); - private $queueKeyPos = array(); - private $queueValues = array(); - private $errorLogged = false; - - - public function __construct($prefix, $config) { - if (!$prefix) { - throw new Exception("Prefix not provided"); - } - - if (!empty($config['disabled'])){ - $this->disabled = true; - return false; - } - - $this->client = new Memcached($prefix); - - // If persistent connection isn't initialized, set it up - if (empty($this->client->getServerList())) { - $this->client->setOptions([ - Memcached::OPT_PREFIX_KEY => $prefix, - Memcached::OPT_LIBKETAMA_COMPATIBLE => true, - Memcached::OPT_BINARY_PROTOCOL => true, - Memcached::OPT_SERIALIZER => Memcached::SERIALIZER_IGBINARY, - Memcached::OPT_NO_BLOCK => true, - Memcached::OPT_TCP_NODELAY => true, - Memcached::OPT_RETRY_TIMEOUT => 10 - ]); - - // Add server connections - foreach ($config['servers'] as $server) { - $hpw = explode(':', $server); - $added = $this->client->addServer( - // host - $hpw[0], - // port - empty($hpw[1]) ? 11211 : $hpw[1], - // weight - empty($hpw[2]) ? 1 : $hpw[2] - ); - } - } - } - - - public function get($keys) { - if ($this->disabled) { - return false; - } - - if (is_array($keys)){ - $multi = true; - } - else if (is_string($keys)) { - if (!$keys) { - throw new Exception("Memcached key not provided"); - } - - // Return values already set within this transaction - if ($this->queuing && isset($this->queueValues[$keys])) { - return $this->queueValues[$keys]; - } - - $multi = false; - } - else { - throw new Exception('$keys must be an array or string'); - } - - $t = microtime(true); - if ($multi) { - $results = $this->client->getMulti($keys); - } - else { - $results = $this->client->get($keys); - } - if ($this->client->getResultCode() != Memcached::RES_SUCCESS - && $this->client->getResultCode() != Memcached::RES_NOTFOUND) { - error_log("Memcached error: " . $this->client->getResultMessage()); - } - - $this->requestTime += microtime(true) - $t; - if (!$multi) { - return $results; - } - - if ($results === false) { - return []; - } - - // Return values already set within this transaction - foreach (array_keys($results) as $key) { - if ($this->queuing && isset($this->queueValues[$key])) { - $results[$key] = $this->queueValues[$key]; - } - } - - return $results; - } - - - public function set($key, $val, $exptime = 0) { - if ($this->disabled) { - return false; - } - - if (!$key) { - throw new Exception("Memcached key not provided"); - } - - if ($this->queuing) { - // If this is already set, mark the previous position for skipping when committing - if (isset($this->queueKeyPos[$key])) { - $pos = $this->queueKeyPos[$key]; - $this->queue[$pos]['skip'] = true; - } - - $this->queue[] = array( - 'op' => 'set', - 'key' => $key, - 'exp' => $exptime - ); - - $this->queueValues[$key] = $val; - - // Store the position in case we need to clear later - $this->queueKeyPos[$key] = sizeOf($this->queue) - 1; - - return true; - } - - $t = microtime(true); - $success = $this->client->set($key, $val, $exptime); - $this->requestTime += microtime(true) - $t; - if (!$success && !$this->errorLogged) { - Z_Core::logError("Setting memcache value failed for key $key: " - . $this->client->getResultMessage() - . " (" . $this->client->getServerByKey($key)['host'] . ")"); - $this->errorLogged = true; - } - return $success; - } - - - public function add($key, $val, $exptime = 0) { - if ($this->disabled) { - return false; - } - - $t = microtime(true); - $retVal = $this->client->add($key, $val, $exptime); - $this->requestTime += microtime(true) - $t; - return $retVal; - } - - - public function delete($key) { - if ($this->disabled){ - return false; - } - $t = microtime(true); - $retVal = $this->client->delete($key); - // Delete from queue too, in case this was set within the current transaction - if ($this->queuing && isset($this->queueValues[$key])) { - unset($this->queueValues[$key]); - } - $this->requestTime += microtime(true) - $t; - return $retVal; - } - - - public function replace($key, $val, $exptime = 0){ - if ($this->disabled){ - return false; - } - $t = microtime(true); - $retVal = $this->client->replace($key, $val, $exptime); - $this->requestTime += microtime(true) - $t; - return $retVal; - } - - - public function increment($key, $value = 1){ - if ($this->disabled){ - return false; - } - $t = microtime(true); - $retVal = $this->client->increment($key, $value); - $this->requestTime += microtime(true) - $t; - return $retVal; - } - - - public function decrement($key, $value = 1){ - if ($this->disabled){ - return false; - } - $t = microtime(true); - $retVal = $this->client->increment($key, $value); - $this->requestTime += microtime(true) - $t; - return $retVal; - } - - public function begin() { - if ($this->queuing) { - //Z_Core::debug("Already queueing memcached transaction"); - return false; - } - //Z_Core::debug("Beginning memcached transaction"); - $this->queuing = true; - return true; - } - - public function commit() { - if (!$this->queuing) { - throw new Exception("Memcache wasn't queuing"); - } - - //Z_Core::debug("Committing memcached transaction"); - - $this->queuing = false; - - if (!$this->queue) { - return; - } - - foreach ($this->queue as $arr) { - if (!empty($arr['skip'])) { - continue; - } - - $op = $arr['op']; - $key = $arr['key']; - // Skip deleted values - if (!isset($this->queueValues[$key])) { - continue; - } - $val = $this->queueValues[$key]; - $exp = $arr['exp']; - - switch ($op) { - case 'set': - case 'add': - break; - - default: - throw new Exception("Unknown operation '$op'"); - } - - $this->$op($key, $val, $exp); - } - $this->queue = array(); - $this->queueKeyPos = array(); - $this->queueValues = array(); - } - - public function rollback() { - if (!$this->queuing) { - Z_Core::debug('Transaction not open in Z_MemcachedClientLocal::rollback()'); - return; - } - - if (!$this->queue) { - return; - } - - $this->queuing = false; - $this->queue = array(); - $this->queueKeyPos = array(); - $this->queueValues = array(); - } -} -?> diff --git a/include/Redis.inc.php b/include/Redis.inc.php deleted file mode 100644 index f469c975..00000000 --- a/include/Redis.inc.php +++ /dev/null @@ -1,73 +0,0 @@ -setOption(RedisCluster::OPT_SERIALIZER, RedisCluster::SERIALIZER_NONE); - - if (!empty(Z_CONFIG::$REDIS_PREFIX)) { - $redis->setOption(RedisCluster::OPT_PREFIX, Z_CONFIG::$REDIS_PREFIX); - } - } - // Non-cluster mode - else { - // Host format can be "host" or "host:port" - $parts = explode(':', Z_CONFIG::$REDIS_HOSTS[$name]['host']); - $host = $parts[0]; - $port = isset($parts[1]) ? $parts[1] : 6379; - - $redis = new Redis(); - // 1s connection timeout, 100ms retry interval - if (!$redis->pconnect($host, $port, 1, NULL, 100)) { - throw new Exception("Redis connection to {$host}:{$port} failed"); - } - - // 1s read timeout - $redis->setOption(Redis::OPT_READ_TIMEOUT, 1); - // No serializer for now, because it's difficult to - // deserialize in other environments - $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE); - if (!empty(Z_CONFIG::$REDIS_PREFIX)) { - $redis->setOption(Redis::OPT_PREFIX, Z_CONFIG::$REDIS_PREFIX); - } - } - return self::$links[$name] = $redis; - } - catch (Exception $e) { - Z_Core::logError($e); - // Cache connection failure - return self::$links[$name] = null; - } - } -} diff --git a/include/RequestLimiter.inc.php b/include/RequestLimiter.inc.php deleted file mode 100644 index a8902a83..00000000 --- a/include/RequestLimiter.inc.php +++ /dev/null @@ -1,189 +0,0 @@ -= 1 - local new_tokens = filled_tokens - if allowed then - new_tokens = filled_tokens - 1 - end - - redis.call("setex", tokens_key, ttl, new_tokens) - redis.call("setex", timestamp_key, ttl, now) - - return { allowed, new_tokens }'; - - // Lua script is based on https://gist.github.com/ptarjan/e38f45f2dfe601419ca3af937fff574d - const CONCURRENCY_LIMITER_LUA = ' - local key = KEYS[1] - - local capacity = tonumber(ARGV[1]) - local id = ARGV[2] - local timestamp = tonumber(ARGV[3]) - local ttl = tonumber(ARGV[4]) - - local count = redis.call("zcard", key) - local allowed = count < capacity - - if allowed then - redis.call("zadd", key, timestamp, id) - redis.call("expire", key, ttl) - end - - return { allowed, count }'; - - protected static $initialized = false; - protected static $redis; - protected static $concurrentRequest = null; - - public static function init() { - if (self::$initialized) return true; - self::$redis = Z_Redis::get('request-limiter'); - if (!self::$redis) return false; - return self::$initialized = true; - } - - public static function isInitialized() { - return self::$initialized; - } - - public static function isConcurrentRequestActive() { - return !!self::$concurrentRequest; - } - - /** - * @param $params {bucket, capacity, replenishRate} - * @return bool|null (true - allowed, false - not allowed, null - error) - */ - public static function checkBucketRate($params) { - if (!isset($params['bucket'], $params['capacity'], $params['replenishRate'])) { - Z_Core::logError('Warning: Misconfigured request rate limit'); - return null; - } - $prefix = self::REDIS_PREFIX .'rrl:' . $params['bucket']; - $keys = [$prefix . '.tk', $prefix . '.ts']; - $args = [$params['replenishRate'], $params['capacity'], time()]; - try { - $res = self::evalLua(self::RATE_LIMITER_LUA, $keys, $args); - } - catch (Exception $e) { - Z_Core::logError("Warning: " . $e); - return null; - } - return !!$res[0]; - } - - /** - * Call this function before the actual request logic - * @param $params {bucket, capacity, ttl} - * @return bool|null (true - allowed, false - not allowed, null - error) - */ - public static function beginConcurrentRequest($params) { - if (!isset($params['bucket'], $params['capacity'], $params['ttl'])) { - Z_Core::logError('Warning: Misconfigured concurrent request limit'); - return null; - } - $id = Zotero_Utilities::randomString(5, 'mixed'); - $timestamp = time(); - $key = self::REDIS_PREFIX . 'crl:' . $params['bucket']; - $args = [$params['capacity'], $id, $timestamp, $params['ttl']]; - try { - // Clear out old requests that got lost (or taking longer than TTL) - $numRemoved = self::$redis->zRemRangeByScore($key, '-inf', $timestamp - $params['ttl']); - if ($numRemoved > 0) { - Z_Core::logError("Warning: Timed out concurrent requests found: {$numRemoved}"); - } - $res = self::evalLua(self::CONCURRENCY_LIMITER_LUA, [$key], $args); - } - catch (Exception $e) { - Z_Core::logError("Warning: " . $e); - return null; - } - - $allowed = !!$res[0]; - if ($allowed) { - self::$concurrentRequest = [ - 'bucket' => $params['bucket'], - 'id' => $id - ]; - } - - return $allowed; - } - - /** - * Call this function after the actual request logic - * @return bool|null (true - success, false - nothing to finish, null - error) - */ - public static function finishConcurrentRequest() { - if (!self::$concurrentRequest) return false; - $key = self::REDIS_PREFIX . 'crl:' . self::$concurrentRequest['bucket']; - try { - if (!self::$redis->zRem($key, self::$concurrentRequest['id'])) { - throw new Exception('Failed to remove an element from a sorted set'); - } - } - catch (Exception $e) { - Z_Core::logError($e); - return null; - } - self::$concurrentRequest = null; - return true; - } - - private static function evalLua($lua, $keys, $args) { - $sha1 = sha1($lua); - $res = self::$redis->evalSha($sha1, array_merge($keys, $args), count($keys)); - if (!$res) { - Z_Core::logError('Warning: Failed to eval Lua script by SHA1'); - $res = self::$redis->eval($lua, array_merge($keys, $args), count($keys)); - if (!$res) { - throw new Exception('Failed to eval Lua script: ' . self::$redis->getLastError()); - } - } - return $res; - } -} diff --git a/include/RollingCurl.inc.php b/include/RollingCurl.inc.php deleted file mode 100644 index cf7d85bd..00000000 --- a/include/RollingCurl.inc.php +++ /dev/null @@ -1,373 +0,0 @@ -url = $url; - $this->method = $method; - $this->post_data = $post_data; - $this->headers = $headers; - $this->options = $options; - } - - /** - * @return void - */ - public function __destruct() { - unset($this->url, $this->method, $this->post_data, $this->headers, $this->options); - } -} - -/** - * RollingCurl custom exception - */ -class RollingCurlException extends Exception {} - -/** - * Class that holds a rolling queue of curl requests. - * - * @throws RollingCurlException - */ -class RollingCurl { - /** - * @var int - * - * Window size is the max number of simultaneous connections allowed. - * - * REMEMBER TO RESPECT THE SERVERS: - * Sending too many requests at one time can easily be perceived - * as a DOS attack. Increase this window_size if you are making requests - * to multiple servers or have permission from the receving server admins. - */ - private $window_size = 5; - - /** - * @var float - * - * Timeout is the timeout used for curl_multi_select. - */ - private $timeout = 10; - - /** - * @var string|array - * - * Callback function to be applied to each result. - */ - private $callback; - - /** - * @var array - * - * Set your base options that you want to be used with EVERY request. - */ - protected $options = array( - CURLOPT_SSL_VERIFYPEER => 0, - CURLOPT_RETURNTRANSFER => 1, - CURLOPT_CONNECTTIMEOUT => 30, - CURLOPT_TIMEOUT => 30 - ); - - /** - * @var array - */ - private $headers = array(); - - /** - * @var Request[] - * - * The request queue - */ - private $requests = array(); - - /** - * @var RequestMap[] - * - * Maps handles to request indexes - */ - private $requestMap = array(); - - /** - * @param $callback - * Callback function to be applied to each result. - * - * Can be specified as 'my_callback_function' - * or array($object, 'my_callback_method'). - * - * Function should take three parameters: $response, $info, $request. - * $response is response body, $info is additional curl info. - * $request is the original request - * - * @return void - */ - function __construct($callback = null) { - $this->callback = $callback; - } - - /** - * @param string $name - * @return mixed - */ - public function __get($name) { - return (isset($this->{$name})) ? $this->{$name} : null; - } - - /** - * @param string $name - * @param mixed $value - * @return bool - */ - public function __set($name, $value){ - // append the base options & headers - if ($name == "options" || $name == "headers") { - $this->{$name} = $value + $this->{$name}; - } else { - $this->{$name} = $value; - } - return true; - } - - /** - * Add a request to the request queue - * - * @param Request $request - * @return bool - */ - public function add($request) { - $this->requests[] = $request; - return true; - } - - /** - * Create new Request and add it to the request queue - * - * @param string $url - * @param string $method - * @param $post_data - * @param $headers - * @param $options - * @return bool - */ - public function request($url, $method = "GET", $post_data = null, $headers = null, $options = null) { - $this->requests[] = new RollingCurlRequest($url, $method, $post_data, $headers, $options); - return true; - } - - /** - * Perform GET request - * - * @param string $url - * @param $headers - * @param $options - * @return bool - */ - public function get($url, $headers = null, $options = null) { - return $this->request($url, "GET", null, $headers, $options); - } - - /** - * Perform POST request - * - * @param string $url - * @param $post_data - * @param $headers - * @param $options - * @return bool - */ - public function post($url, $post_data = null, $headers = null, $options = null) { - return $this->request($url, "POST", $post_data, $headers, $options); - } - - /** - * Execute the curl - * - * @param int $window_size Max number of simultaneous connections - * @return string|bool - */ - public function execute($window_size = null) { - // rolling curl window must always be greater than 1 - if (sizeof($this->requests) == 1) { - return $this->single_curl(); - } else { - // start the rolling curl. window_size is the max number of simultaneous connections - return $this->rolling_curl($window_size); - } - } - - /** - * Performs a single curl request - * - * @access private - * @return string - */ - private function single_curl() { - $ch = curl_init(); - $request = array_shift($this->requests); - $options = $this->get_options($request); - curl_setopt_array($ch,$options); - $output = curl_exec($ch); - $info = curl_getinfo($ch); - - // it's not neccesary to set a callback for one-off requests - if ($this->callback) { - $callback = $this->callback; - if (is_callable($this->callback)){ - call_user_func($callback, $output, $info, $request); - } - } - else - return $output; - return true; - } - - /** - * Performs multiple curl requests - * - * @access private - * @throws RollingCurlException - * @param int $window_size Max number of simultaneous connections - * @return bool - */ - private function rolling_curl($window_size = null) { - if ($window_size) - $this->window_size = $window_size; - - // make sure the rolling window isn't greater than the # of urls - if (sizeof($this->requests) < $this->window_size) - $this->window_size = sizeof($this->requests); - - if ($this->window_size < 2) { - throw new RollingCurlException("Window size must be greater than 1"); - } - - $master = curl_multi_init(); - - // start the first batch of requests - for ($i = 0; $i < $this->window_size; $i++) { - $ch = curl_init(); - - $options = $this->get_options($this->requests[$i]); - - curl_setopt_array($ch,$options); - curl_multi_add_handle($master, $ch); - - // Add to our request Maps - $key = (string) $ch; - $this->requestMap[$key] = $i; - } - - do { - while(($execrun = curl_multi_exec($master, $running)) == CURLM_CALL_MULTI_PERFORM); - if($execrun != CURLM_OK) - break; - // a request was just completed -- find out which one - while($done = curl_multi_info_read($master)) { - - // get the info and content returned on the request - $info = curl_getinfo($done['handle']); - $output = curl_multi_getcontent($done['handle']); - - // send the return values to the callback function. - $callback = $this->callback; - if (is_callable($callback)){ - $key = (string)$done['handle']; - $request = $this->requests[$this->requestMap[$key]]; - unset($this->requestMap[$key]); - call_user_func($callback, $output, $info, $request); - } - - // start a new request (it's important to do this before removing the old one) - if ($i < sizeof($this->requests) && isset($this->requests[$i]) && $i < count($this->requests)) { - $ch = curl_init(); - $options = $this->get_options($this->requests[$i]); - curl_setopt_array($ch,$options); - curl_multi_add_handle($master, $ch); - - // Add to our request Maps - $key = (string) $ch; - $this->requestMap[$key] = $i; - $i++; - } - - // remove the curl handle that just completed - curl_multi_remove_handle($master, $done['handle']); - - } - - // Block for data in / output; error handling is done by curl_multi_exec - if ($running) - curl_multi_select($master, $this->timeout); - - } while ($running); - curl_multi_close($master); - return true; - } - - - /** - * Helper function to set up a new request by setting the appropriate options - * - * @access private - * @param Request $request - * @return array - */ - private function get_options($request) { - // options for this entire curl object - $options = $this->__get('options'); - if (ini_get('safe_mode') == 'Off' || !ini_get('safe_mode')) { - $options[CURLOPT_FOLLOWLOCATION] = 1; - $options[CURLOPT_MAXREDIRS] = 5; - } - $headers = $this->__get('headers'); - - // append custom options for this specific request - if ($request->options) { - $options = $request->options + $options; - } - - // set the request URL - $options[CURLOPT_URL] = $request->url; - - // posting data w/ this request? - if ($request->post_data) { - $options[CURLOPT_POST] = 1; - $options[CURLOPT_POSTFIELDS] = $request->post_data; - } - if ($headers) { - $options[CURLOPT_HEADER] = 0; - $options[CURLOPT_HTTPHEADER] = $headers; - } - - return $options; - } - - /** - * @return void - */ - public function __destruct() { - unset($this->window_size, $this->callback, $this->options, $this->headers, $this->requests); - } -} diff --git a/include/SQS.inc.php b/include/SQS.inc.php deleted file mode 100644 index 88b870f3..00000000 --- a/include/SQS.inc.php +++ /dev/null @@ -1,108 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Z_SQS { - private static $sqs; - - public static function send($queueURL, $message) { - self::load(); - Z_Core::debug("Sending SQS message to $queueURL", 4); - $response = self::$sqs->sendMessage([ - 'QueueUrl' => $queueURL, - 'MessageBody' => $message - ]); - return $response; - } - - - public static function sendBatch($queueURL, $messages) { - self::load(); - - if (sizeOf($messages) > 10) { - throw new Exception("Only 10 messages can be sent at a time"); - } - - $num = sizeOf($messages); - Z_Core::debug("Sending " . $num . " message to SQS" . ($num === 1 ? "" : "s")); - - $entries = array(); - foreach ($messages as $message) { - $entries[] = array( - 'Id' => uniqid(), - 'MessageBody' => $message - ); - } - - $response = self::$sqs->sendMessageBatch([ - 'QueueUrl' => $queueURL, - 'Entries' => $entries - ]); - return $response; - } - - - public static function receive($queueURL) { - self::load(); - $response = self::$sqs->receiveMessage([ - 'QueueUrl' => $queueURL - ]); - return $response; - } - - - public static function delete($queueURL, $receiptHandle) { - $response = self::$sqs->deleteMessage([ - 'QueueUrl' => $queueURL, - 'ReceiptHandle' => $receiptHandle - ]); - return $response; - } - - - public static function deleteBatch($queueURL, $batchEntries) { - Z_Core::debug("Deleting " . sizeOf($batchEntries) . " messages from $queueURL", 4); - $response = self::$sqs->deleteMessageBatch([ - 'QueueUrl' => $queueURL, - 'Entries' => $batchEntries - ]); - $response = self::processResponse($response); - if (!$response) { - return false; - } - foreach ($response->body->DeleteMessageBatchResult[0]->BatchResultErrorEntry as $error) { - error_log("Error deleting SQS message: " - . $error->Code . ": " . $error->Message); - } - return $response->body->DeleteMessageBatchResult[0]->DeleteMessageBatchResultEntry->count(); - } - - - private static function load() { - if (!self::$sqs) { - self::$sqs = Z_Core::$AWS->get('sqs'); - } - } -} diff --git a/include/Scribe.php b/include/Scribe.php deleted file mode 100644 index 90409030..00000000 --- a/include/Scribe.php +++ /dev/null @@ -1,248 +0,0 @@ -send_Log($messages); - return $this->recv_Log(); - } - - public function send_Log($messages) - { - $args = new scribe_Log_args(); - $args->messages = $messages; - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'Log', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('Log', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_Log() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'scribe_Log_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new scribe_Log_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - if ($result->success !== null) { - return $result->success; - } - throw new Exception("Log failed: unknown result"); - } - -} - -// HELPER FUNCTIONS AND STRUCTURES - -class scribe_Log_args { - static $_TSPEC; - - public $messages = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 1 => array( - 'var' => 'messages', - 'type' => TType::LST, - 'etype' => TType::STRUCT, - 'elem' => array( - 'type' => TType::STRUCT, - 'class' => 'LogEntry', - ), - ), - ); - } - if (is_array($vals)) { - if (isset($vals['messages'])) { - $this->messages = $vals['messages']; - } - } - } - - public function getName() { - return 'scribe_Log_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 1: - if ($ftype == TType::LST) { - $this->messages = array(); - $_size0 = 0; - $_etype3 = 0; - $xfer += $input->readListBegin($_etype3, $_size0); - for ($_i4 = 0; $_i4 < $_size0; ++$_i4) - { - $elem5 = null; - $elem5 = new LogEntry(); - $xfer += $elem5->read($input); - $this->messages []= $elem5; - } - $xfer += $input->readListEnd(); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('scribe_Log_args'); - if ($this->messages !== null) { - if (!is_array($this->messages)) { - throw new TProtocolException('Bad type in structure.', TProtocolException::INVALID_DATA); - } - $xfer += $output->writeFieldBegin('messages', TType::LST, 1); - { - $output->writeListBegin(TType::STRUCT, count($this->messages)); - { - foreach ($this->messages as $iter6) - { - $xfer += $iter6->write($output); - } - } - $output->writeListEnd(); - } - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class scribe_Log_result { - static $_TSPEC; - - public $success = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 0 => array( - 'var' => 'success', - 'type' => TType::I32, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['success'])) { - $this->success = $vals['success']; - } - } - } - - public function getName() { - return 'scribe_Log_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 0: - if ($ftype == TType::I32) { - $xfer += $input->readI32($this->success); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('scribe_Log_result'); - if ($this->success !== null) { - $xfer += $output->writeFieldBegin('success', TType::I32, 0); - $xfer += $output->writeI32($this->success); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -?> diff --git a/include/Shards.inc.php b/include/Shards.inc.php deleted file mode 100644 index ecdde471..00000000 --- a/include/Shards.inc.php +++ /dev/null @@ -1,362 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_Shards { - private static $libraryShards = array(); - private static $shardInfo = array(); - private static $shardHostReplicas = []; - - public static function getShardInfo($shardID) { - if (!$shardID) { - throw new Exception('$shardID not provided'); - } - - if (isset(self::$shardInfo[$shardID])) { - return self::$shardInfo[$shardID]; - } - - $cacheKey = 'shardInfo_' . $shardID; - $shardInfo = Z_Core::$MC->get($cacheKey); - if ($shardInfo) { - self::$shardInfo[$shardID] = $shardInfo; - return $shardInfo; - } - - // TEMP: Extra 'address' - $sql = "SELECT address, address AS host, port, db, - CASE - WHEN shardHosts.state='up' THEN shards.state - WHEN shardHosts.state='readonly' THEN - IF(shards.state='down', 'down', 'readonly') - WHEN shardHosts.state='down' THEN 'down' - END AS state, shardHostID - FROM shards JOIN shardHosts USING (shardHostID) WHERE shardID=?"; - $shardInfo = Zotero_DB::rowQuery($sql, $shardID); - if (!$shardInfo) { - throw new Exception("Shard $shardID not found"); - } - - self::$shardInfo[$shardID] = $shardInfo; - Z_Core::$MC->set($cacheKey, $shardInfo, 60); - - return $shardInfo; - } - - - public static function shardIsWriteable($shardID) { - $shardInfo = self::getShardInfo($shardID); - return $shardInfo['state'] == 'up'; - } - - - public static function getReplicaInfo($shardHostID) { - if (!$shardHostID) { - throw new Exception('$shardHostID not provided'); - } - - if (isset(self::$shardHostReplicas[$shardHostID])) { - return self::$shardHostReplicas[$shardHostID]; - } - - $cacheKey = 'shardHostReplicas_' . $shardHostID; - $replicaInfo = Z_Core::$MC->get($cacheKey); - if ($replicaInfo !== false) { - self::$shardHostReplicas[$shardHostID] = $replicaInfo; - return $replicaInfo; - } - - // TEMP: Extra 'address' - $sql = "SELECT address, address AS host, port, state FROM shardHostReplicas " - . "WHERE shardHostID=? AND state='up'"; - $replicaInfo = Zotero_DB::query($sql, $shardHostID); - if (!$replicaInfo) { - $replicaInfo = []; - self::$shardHostReplicas[$shardHostID] = $replicaInfo; - Z_Core::$MC->set($cacheKey, $replicaInfo, 60); - return $replicaInfo; - } - - self::$shardHostReplicas[$shardHostID] = $replicaInfo; - Z_Core::$MC->set($cacheKey, $replicaInfo, 60); - - return $replicaInfo; - } - - - public static function getByLibraryID($libraryID) { - if (is_null($libraryID)) { - throw new Exception('$libraryID not provided'); - } - - if (isset(self::$libraryShards[$libraryID])) { - return self::$libraryShards[$libraryID]; - } - - $cacheKey = 'libraryShard_' . $libraryID; - $shardID = Z_Core::$MC->get($cacheKey); - if ($shardID) { - self::$libraryShards[$libraryID] = $shardID; - return $shardID; - } - - $sql = "SELECT shardID FROM libraries WHERE libraryID=?"; - $shardID = Zotero_DB::valueQuery($sql, $libraryID); - if (!$shardID) { - throw new Exception("Shard not found for library $libraryID"); - } - - self::$libraryShards[$libraryID] = $shardID; - Z_Core::$MC->set($cacheKey, $shardID, 86400); - - return $shardID; - } - - - public static function getByUserID($userID) { - $libraryID = Zotero_Users::getLibraryIDFromUserID($userID); - return self::getByLibraryID($libraryID); - } - - - public static function getByGroupID($groupID) { - $libraryID = Zotero_Groups::getLibraryIDFromGroupID($groupID); - return self::getByLibraryID($libraryID); - } - - - /** - * Returns shardIDs of all shards storing libraries this user belongs to - */ - public static function getUserShards($userID) { - return array_unique( - array_merge( - array(self::getByUserID($userID)), - Zotero_Groups::getUserGroupShards($userID) - ) - ); - } - - - /** - * Get one of the smallest shards - */ - public static function getNextShard() { - $sql = "SELECT shardID FROM shards ORDER BY items ASC LIMIT 10"; - $shards = Zotero_DB::columnQuery($sql); - return $shards[array_rand($shards, 1)]; - } - - - public static function getAllShards($state=false) { - $sql = "SELECT shardID FROM shards S JOIN shardHosts SH USING (shardHostID)"; - if ($state) { - $sql .= " WHERE SH.state=? AND S.state=?"; - $params = array($state, $state); - } - else { - $params = array(); - } - - return Zotero_DB::columnQuery($sql, $params); - } - - - private static function setShard($libraryID, $newShardID) { - $currentShardID = self::getByLibraryID($libraryID); - if ($currentShardID == $newShardID) { - throw new Exception("Library $libraryID is already on shard $newShardID"); - } - - unset(self::$libraryShards[$libraryID]); - - $cacheKey = 'libraryShard_' . $libraryID; - Z_Core::$MC->delete($cacheKey); - - $sql = "UPDATE libraries SET shardID=? WHERE libraryID=?"; - Zotero_DB::query($sql, array($newShardID, $libraryID)); - } - - - public static function moveLibrary($libraryID, $newShardID, $overrideLock=false) { - $currentShardID = self::getByLibraryID($libraryID); - - self::copyLibrary($libraryID, $newShardID, $overrideLock); - - self::setShard($libraryID, $newShardID); - - self::deleteLibrary($libraryID, $currentShardID); - } - - - public static function copyLibrary($libraryID, $newShardID, $overrideLock=false) { - $currentShardID = self::getByLibraryID($libraryID); - - if ($currentShardID == $newShardID) { - throw new Exception("Library $libraryID is already on shard $newShardID"); - } - if (!self::shardIsWriteable($newShardID)) { - throw new Exception("Shard $newShardID is not writeable"); - } - - if (!$overrideLock && Zotero_Libraries::isLocked($libraryID)) { - throw new Exception("Library $libraryID is locked"); - } - - // Make sure there's no stale data on the new shard - if (self::checkForLibrary($libraryID, $newShardID)) { - throw new Exception("Library $libraryID data already exists on shard $newShardID"); - } - - Zotero_DB::beginTransaction(); - - Zotero_DB::query("SET foreign_key_checks=0", false, $newShardID); - - $tables = array( - 'shardLibraries', - 'collections', - 'creators', - 'items', - 'relations', - 'savedSearches', - 'settings', - 'tags', - 'collectionItems', - 'deletedItems', - 'groupItems', - 'itemAttachments', - 'itemCreators', - 'itemData', - 'itemNotes', - 'itemRelated', - 'itemSortFields', - 'itemTags', - 'publicationsItems', - 'savedSearchConditions', - 'storageFileItems', - 'syncDeleteLogIDs', - 'syncDeleteLogKeys' - ); - - foreach ($tables as $table) { - if (!$overrideLock && Zotero_Libraries::isLocked($libraryID)) { - Zotero_DB::rollback(); - throw new Exception("Aborted due to library lock"); - } - - switch ($table) { - case 'collections': - case 'creators': - case 'items': - case 'relations': - case 'savedSearches': - case 'settings': - case 'shardLibraries': - case 'syncDeleteLogIDs': - case 'syncDeleteLogKeys': - case 'tags': - $sql = "SELECT * FROM $table WHERE libraryID=?"; - break; - - case 'collectionItems': - case 'deletedItems': - case 'groupItems': - case 'itemAttachments': - case 'itemCreators': - case 'itemData': - case 'itemNotes': - case 'itemRelated': - case 'itemSortFields': - case 'itemTags': - case 'publicationsItems': - case 'storageFileItems': - $sql = "SELECT T.* FROM $table T JOIN items USING (itemID) WHERE libraryID=?"; - break; - - case 'savedSearchConditions': - $sql = "SELECT SSC.* FROM savedSearchConditions SSC - JOIN savedSearches USING (searchID) WHERE libraryID=?"; - break; - } - - $rows = Zotero_DB::query($sql, $libraryID, $currentShardID); - - if ($rows) { - $sets = array(); - foreach ($rows as $row) { - $sets[] = array_values($row); - } - $sql = "INSERT INTO $table VALUES "; - Zotero_DB::bulkInsert($sql, $sets, 50, false, $newShardID); - } - } - - - Zotero_DB::query("SET foreign_key_checks=1", false, $newShardID); - - if (!$overrideLock && Zotero_Libraries::isLocked($libraryID)) { - Zotero_DB::rollback(); - throw new Exception("Aborted due to library lock"); - } - - Zotero_DB::commit(); - - if (!$overrideLock && Zotero_Libraries::isLocked($libraryID)) { - self::deleteLibrary($libraryID, $newShardID); - throw new Exception("Aborted due to library lock"); - } - } - - - private static function checkForLibrary($libraryID, $shardID) { - $tables = array( - 'collections', - 'creators', - 'items', - 'relations', - 'savedSearches', - 'shardLibraries', - 'tags', - 'syncDeleteLogIDs', - 'syncDeleteLogKeys' - ); - - foreach ($tables as $table) { - $sql = "SELECT COUNT(*) FROM $table WHERE libraryID=?"; - if (Zotero_DB::valueQuery($sql, $libraryID, $shardID)) { - return true; - } - } - - return false; - } - - - private static function deleteLibrary($libraryID, $shardID) { - $sql = "DELETE FROM shardLibraries WHERE libraryID=?"; - Zotero_DB::query($sql, $libraryID, $shardID); - } -} -?> diff --git a/include/StatsD.inc.php b/include/StatsD.inc.php deleted file mode 100644 index 56b01593..00000000 --- a/include/StatsD.inc.php +++ /dev/null @@ -1,96 +0,0 @@ - "$time|ms"), $sampleRate); - } - - /** - * Increments one or more stats counters - * - * @param string|array $stats The metric(s) to increment. - * @param float|1 $sampleRate the rate (0-1) for sampling. - * @return boolean - **/ - public static function increment($stats, $sampleRate=1) { - StatsD::updateStats($stats, 1, $sampleRate); - } - - /** - * Decrements one or more stats counters. - * - * @param string|array $stats The metric(s) to decrement. - * @param float|1 $sampleRate the rate (0-1) for sampling. - * @return boolean - **/ - public static function decrement($stats, $sampleRate=1) { - StatsD::updateStats($stats, -1, $sampleRate); - } - - /** - * Updates one or more stats counters by arbitrary amounts. - * - * @param string|array $stats The metric(s) to update. Should be either a string or array of metrics. - * @param int|1 $delta The amount to increment/decrement each metric by. - * @param float|1 $sampleRate the rate (0-1) for sampling. - * @return boolean - **/ - public static function updateStats($stats, $delta=1, $sampleRate=1) { - if (!is_array($stats)) { $stats = array($stats); } - $data = array(); - foreach($stats as $stat) { - $data[$stat] = "$delta|c"; - } - - StatsD::send($data, $sampleRate); - } - - /* - * Squirt the metrics over UDP - **/ - public static function send($data, $sampleRate=1) { - if (empty(Z_CONFIG::$STATSD_ENABLED)) { return; } - - // sampling - $sampledData = array(); - - if ($sampleRate < 1) { - foreach ($data as $stat => $value) { - if ((mt_rand() / mt_getrandmax()) <= $sampleRate) { - $sampledData[$stat] = "$value|@$sampleRate"; - } - } - } else { - $sampledData = $data; - } - - if (empty($sampledData)) { return; } - - // Wrap this in a try/catch - failures in any of this should be silently ignored - try { - $host = Z_CONFIG::$STATSD_HOST; - $port = Z_CONFIG::$STATSD_PORT; - $fp = fsockopen("udp://$host", $port, $errno, $errstr); - if (! $fp) { return; } - foreach ($sampledData as $stat => $value) { - fwrite($fp, Z_CONFIG::$STATSD_PREFIX . "$stat:$value"); - } - fclose($fp); - } catch (Exception $e) { - } - } -} \ No newline at end of file diff --git a/include/Thrift/Thrift.php b/include/Thrift/Thrift.php deleted file mode 100644 index ef6ab8a4..00000000 --- a/include/Thrift/Thrift.php +++ /dev/null @@ -1,787 +0,0 @@ - $fspec) { - $var = $fspec['var']; - if (isset($vals[$var])) { - $this->$var = $vals[$var]; - } - } - } else { - parent::__construct($p1, $p2); - } - } - - static $tmethod = array(TType::BOOL => 'Bool', - TType::BYTE => 'Byte', - TType::I16 => 'I16', - TType::I32 => 'I32', - TType::I64 => 'I64', - TType::DOUBLE => 'Double', - TType::STRING => 'String'); - - private function _readMap(&$var, $spec, $input) { - $xfer = 0; - $ktype = $spec['ktype']; - $vtype = $spec['vtype']; - $kread = $vread = null; - if (isset(TBase::$tmethod[$ktype])) { - $kread = 'read'.TBase::$tmethod[$ktype]; - } else { - $kspec = $spec['key']; - } - if (isset(TBase::$tmethod[$vtype])) { - $vread = 'read'.TBase::$tmethod[$vtype]; - } else { - $vspec = $spec['val']; - } - $var = array(); - $_ktype = $_vtype = $size = 0; - $xfer += $input->readMapBegin($_ktype, $_vtype, $size); - for ($i = 0; $i < $size; ++$i) { - $key = $val = null; - if ($kread !== null) { - $xfer += $input->$kread($key); - } else { - switch ($ktype) { - case TType::STRUCT: - $class = $kspec['class']; - $key = new $class(); - $xfer += $key->read($input); - break; - case TType::MAP: - $xfer += $this->_readMap($key, $kspec, $input); - break; - case TType::LST: - $xfer += $this->_readList($key, $kspec, $input, false); - break; - case TType::SET: - $xfer += $this->_readList($key, $kspec, $input, true); - break; - } - } - if ($vread !== null) { - $xfer += $input->$vread($val); - } else { - switch ($vtype) { - case TType::STRUCT: - $class = $vspec['class']; - $val = new $class(); - $xfer += $val->read($input); - break; - case TType::MAP: - $xfer += $this->_readMap($val, $vspec, $input); - break; - case TType::LST: - $xfer += $this->_readList($val, $vspec, $input, false); - break; - case TType::SET: - $xfer += $this->_readList($val, $vspec, $input, true); - break; - } - } - $var[$key] = $val; - } - $xfer += $input->readMapEnd(); - return $xfer; - } - - private function _readList(&$var, $spec, $input, $set=false) { - $xfer = 0; - $etype = $spec['etype']; - $eread = $vread = null; - if (isset(TBase::$tmethod[$etype])) { - $eread = 'read'.TBase::$tmethod[$etype]; - } else { - $espec = $spec['elem']; - } - $var = array(); - $_etype = $size = 0; - if ($set) { - $xfer += $input->readSetBegin($_etype, $size); - } else { - $xfer += $input->readListBegin($_etype, $size); - } - for ($i = 0; $i < $size; ++$i) { - $elem = null; - if ($eread !== null) { - $xfer += $input->$eread($elem); - } else { - $espec = $spec['elem']; - switch ($etype) { - case TType::STRUCT: - $class = $espec['class']; - $elem = new $class(); - $xfer += $elem->read($input); - break; - case TType::MAP: - $xfer += $this->_readMap($elem, $espec, $input); - break; - case TType::LST: - $xfer += $this->_readList($elem, $espec, $input, false); - break; - case TType::SET: - $xfer += $this->_readList($elem, $espec, $input, true); - break; - } - } - if ($set) { - $var[$elem] = true; - } else { - $var []= $elem; - } - } - if ($set) { - $xfer += $input->readSetEnd(); - } else { - $xfer += $input->readListEnd(); - } - return $xfer; - } - - protected function _read($class, $spec, $input) { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - if (isset($spec[$fid])) { - $fspec = $spec[$fid]; - $var = $fspec['var']; - if ($ftype == $fspec['type']) { - $xfer = 0; - if (isset(TBase::$tmethod[$ftype])) { - $func = 'read'.TBase::$tmethod[$ftype]; - $xfer += $input->$func($this->$var); - } else { - switch ($ftype) { - case TType::STRUCT: - $class = $fspec['class']; - $this->$var = new $class(); - $xfer += $this->$var->read($input); - break; - case TType::MAP: - $xfer += $this->_readMap($this->$var, $fspec, $input); - break; - case TType::LST: - $xfer += $this->_readList($this->$var, $fspec, $input, false); - break; - case TType::SET: - $xfer += $this->_readList($this->$var, $fspec, $input, true); - break; - } - } - } else { - $xfer += $input->skip($ftype); - } - } else { - $xfer += $input->skip($ftype); - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - private function _writeMap($var, $spec, $output) { - $xfer = 0; - $ktype = $spec['ktype']; - $vtype = $spec['vtype']; - $kwrite = $vwrite = null; - if (isset(TBase::$tmethod[$ktype])) { - $kwrite = 'write'.TBase::$tmethod[$ktype]; - } else { - $kspec = $spec['key']; - } - if (isset(TBase::$tmethod[$vtype])) { - $vwrite = 'write'.TBase::$tmethod[$vtype]; - } else { - $vspec = $spec['val']; - } - $xfer += $output->writeMapBegin($ktype, $vtype, count($var)); - foreach ($var as $key => $val) { - if (isset($kwrite)) { - $xfer += $output->$kwrite($key); - } else { - switch ($ktype) { - case TType::STRUCT: - $xfer += $key->write($output); - break; - case TType::MAP: - $xfer += $this->_writeMap($key, $kspec, $output); - break; - case TType::LST: - $xfer += $this->_writeList($key, $kspec, $output, false); - break; - case TType::SET: - $xfer += $this->_writeList($key, $kspec, $output, true); - break; - } - } - if (isset($vwrite)) { - $xfer += $output->$vwrite($val); - } else { - switch ($vtype) { - case TType::STRUCT: - $xfer += $val->write($output); - break; - case TType::MAP: - $xfer += $this->_writeMap($val, $vspec, $output); - break; - case TType::LST: - $xfer += $this->_writeList($val, $vspec, $output, false); - break; - case TType::SET: - $xfer += $this->_writeList($val, $vspec, $output, true); - break; - } - } - } - $xfer += $output->writeMapEnd(); - return $xfer; - } - - private function _writeList($var, $spec, $output, $set=false) { - $xfer = 0; - $etype = $spec['etype']; - $ewrite = null; - if (isset(TBase::$tmethod[$etype])) { - $ewrite = 'write'.TBase::$tmethod[$etype]; - } else { - $espec = $spec['elem']; - } - if ($set) { - $xfer += $output->writeSetBegin($etype, count($var)); - } else { - $xfer += $output->writeListBegin($etype, count($var)); - } - foreach ($var as $key => $val) { - $elem = $set ? $key : $val; - if (isset($ewrite)) { - $xfer += $output->$ewrite($elem); - } else { - switch ($etype) { - case TType::STRUCT: - $xfer += $elem->write($output); - break; - case TType::MAP: - $xfer += $this->_writeMap($elem, $espec, $output); - break; - case TType::LST: - $xfer += $this->_writeList($elem, $espec, $output, false); - break; - case TType::SET: - $xfer += $this->_writeList($elem, $espec, $output, true); - break; - } - } - } - if ($set) { - $xfer += $output->writeSetEnd(); - } else { - $xfer += $output->writeListEnd(); - } - return $xfer; - } - - protected function _write($class, $spec, $output) { - $xfer = 0; - $xfer += $output->writeStructBegin($class); - foreach ($spec as $fid => $fspec) { - $var = $fspec['var']; - if ($this->$var !== null) { - $ftype = $fspec['type']; - $xfer += $output->writeFieldBegin($var, $ftype, $fid); - if (isset(TBase::$tmethod[$ftype])) { - $func = 'write'.TBase::$tmethod[$ftype]; - $xfer += $output->$func($this->$var); - } else { - switch ($ftype) { - case TType::STRUCT: - $xfer += $this->$var->write($output); - break; - case TType::MAP: - $xfer += $this->_writeMap($this->$var, $fspec, $output); - break; - case TType::LST: - $xfer += $this->_writeList($this->$var, $fspec, $output, false); - break; - case TType::SET: - $xfer += $this->_writeList($this->$var, $fspec, $output, true); - break; - } - } - $xfer += $output->writeFieldEnd(); - } - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -/** - * Base class from which other Thrift structs extend. This is so that we can - * cut back on the size of the generated code which is turning out to have a - * nontrivial cost just to load thanks to the wondrously abysmal implementation - * of PHP. Note that code is intentionally duplicated in here to avoid making - * function calls for every field or member of a container.. - */ -abstract class TBase { - - static $tmethod = array(TType::BOOL => 'Bool', - TType::BYTE => 'Byte', - TType::I16 => 'I16', - TType::I32 => 'I32', - TType::I64 => 'I64', - TType::DOUBLE => 'Double', - TType::STRING => 'String'); - - abstract function read($input); - - abstract function write($output); - - public function __construct($spec=null, $vals=null) { - if (is_array($spec) && is_array($vals)) { - foreach ($spec as $fid => $fspec) { - $var = $fspec['var']; - if (isset($vals[$var])) { - $this->$var = $vals[$var]; - } - } - } - } - - private function _readMap(&$var, $spec, $input) { - $xfer = 0; - $ktype = $spec['ktype']; - $vtype = $spec['vtype']; - $kread = $vread = null; - if (isset(TBase::$tmethod[$ktype])) { - $kread = 'read'.TBase::$tmethod[$ktype]; - } else { - $kspec = $spec['key']; - } - if (isset(TBase::$tmethod[$vtype])) { - $vread = 'read'.TBase::$tmethod[$vtype]; - } else { - $vspec = $spec['val']; - } - $var = array(); - $_ktype = $_vtype = $size = 0; - $xfer += $input->readMapBegin($_ktype, $_vtype, $size); - for ($i = 0; $i < $size; ++$i) { - $key = $val = null; - if ($kread !== null) { - $xfer += $input->$kread($key); - } else { - switch ($ktype) { - case TType::STRUCT: - $class = $kspec['class']; - $key = new $class(); - $xfer += $key->read($input); - break; - case TType::MAP: - $xfer += $this->_readMap($key, $kspec, $input); - break; - case TType::LST: - $xfer += $this->_readList($key, $kspec, $input, false); - break; - case TType::SET: - $xfer += $this->_readList($key, $kspec, $input, true); - break; - } - } - if ($vread !== null) { - $xfer += $input->$vread($val); - } else { - switch ($vtype) { - case TType::STRUCT: - $class = $vspec['class']; - $val = new $class(); - $xfer += $val->read($input); - break; - case TType::MAP: - $xfer += $this->_readMap($val, $vspec, $input); - break; - case TType::LST: - $xfer += $this->_readList($val, $vspec, $input, false); - break; - case TType::SET: - $xfer += $this->_readList($val, $vspec, $input, true); - break; - } - } - $var[$key] = $val; - } - $xfer += $input->readMapEnd(); - return $xfer; - } - - private function _readList(&$var, $spec, $input, $set=false) { - $xfer = 0; - $etype = $spec['etype']; - $eread = $vread = null; - if (isset(TBase::$tmethod[$etype])) { - $eread = 'read'.TBase::$tmethod[$etype]; - } else { - $espec = $spec['elem']; - } - $var = array(); - $_etype = $size = 0; - if ($set) { - $xfer += $input->readSetBegin($_etype, $size); - } else { - $xfer += $input->readListBegin($_etype, $size); - } - for ($i = 0; $i < $size; ++$i) { - $elem = null; - if ($eread !== null) { - $xfer += $input->$eread($elem); - } else { - $espec = $spec['elem']; - switch ($etype) { - case TType::STRUCT: - $class = $espec['class']; - $elem = new $class(); - $xfer += $elem->read($input); - break; - case TType::MAP: - $xfer += $this->_readMap($elem, $espec, $input); - break; - case TType::LST: - $xfer += $this->_readList($elem, $espec, $input, false); - break; - case TType::SET: - $xfer += $this->_readList($elem, $espec, $input, true); - break; - } - } - if ($set) { - $var[$elem] = true; - } else { - $var []= $elem; - } - } - if ($set) { - $xfer += $input->readSetEnd(); - } else { - $xfer += $input->readListEnd(); - } - return $xfer; - } - - protected function _read($class, $spec, $input) { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - if (isset($spec[$fid])) { - $fspec = $spec[$fid]; - $var = $fspec['var']; - if ($ftype == $fspec['type']) { - $xfer = 0; - if (isset(TBase::$tmethod[$ftype])) { - $func = 'read'.TBase::$tmethod[$ftype]; - $xfer += $input->$func($this->$var); - } else { - switch ($ftype) { - case TType::STRUCT: - $class = $fspec['class']; - $this->$var = new $class(); - $xfer += $this->$var->read($input); - break; - case TType::MAP: - $xfer += $this->_readMap($this->$var, $fspec, $input); - break; - case TType::LST: - $xfer += $this->_readList($this->$var, $fspec, $input, false); - break; - case TType::SET: - $xfer += $this->_readList($this->$var, $fspec, $input, true); - break; - } - } - } else { - $xfer += $input->skip($ftype); - } - } else { - $xfer += $input->skip($ftype); - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - private function _writeMap($var, $spec, $output) { - $xfer = 0; - $ktype = $spec['ktype']; - $vtype = $spec['vtype']; - $kwrite = $vwrite = null; - if (isset(TBase::$tmethod[$ktype])) { - $kwrite = 'write'.TBase::$tmethod[$ktype]; - } else { - $kspec = $spec['key']; - } - if (isset(TBase::$tmethod[$vtype])) { - $vwrite = 'write'.TBase::$tmethod[$vtype]; - } else { - $vspec = $spec['val']; - } - $xfer += $output->writeMapBegin($ktype, $vtype, count($var)); - foreach ($var as $key => $val) { - if (isset($kwrite)) { - $xfer += $output->$kwrite($key); - } else { - switch ($ktype) { - case TType::STRUCT: - $xfer += $key->write($output); - break; - case TType::MAP: - $xfer += $this->_writeMap($key, $kspec, $output); - break; - case TType::LST: - $xfer += $this->_writeList($key, $kspec, $output, false); - break; - case TType::SET: - $xfer += $this->_writeList($key, $kspec, $output, true); - break; - } - } - if (isset($vwrite)) { - $xfer += $output->$vwrite($val); - } else { - switch ($vtype) { - case TType::STRUCT: - $xfer += $val->write($output); - break; - case TType::MAP: - $xfer += $this->_writeMap($val, $vspec, $output); - break; - case TType::LST: - $xfer += $this->_writeList($val, $vspec, $output, false); - break; - case TType::SET: - $xfer += $this->_writeList($val, $vspec, $output, true); - break; - } - } - } - $xfer += $output->writeMapEnd(); - return $xfer; - } - - private function _writeList($var, $spec, $output, $set=false) { - $xfer = 0; - $etype = $spec['etype']; - $ewrite = null; - if (isset(TBase::$tmethod[$etype])) { - $ewrite = 'write'.TBase::$tmethod[$etype]; - } else { - $espec = $spec['elem']; - } - if ($set) { - $xfer += $output->writeSetBegin($etype, count($var)); - } else { - $xfer += $output->writeListBegin($etype, count($var)); - } - foreach ($var as $key => $val) { - $elem = $set ? $key : $val; - if (isset($ewrite)) { - $xfer += $output->$ewrite($elem); - } else { - switch ($etype) { - case TType::STRUCT: - $xfer += $elem->write($output); - break; - case TType::MAP: - $xfer += $this->_writeMap($elem, $espec, $output); - break; - case TType::LST: - $xfer += $this->_writeList($elem, $espec, $output, false); - break; - case TType::SET: - $xfer += $this->_writeList($elem, $espec, $output, true); - break; - } - } - } - if ($set) { - $xfer += $output->writeSetEnd(); - } else { - $xfer += $output->writeListEnd(); - } - return $xfer; - } - - protected function _write($class, $spec, $output) { - $xfer = 0; - $xfer += $output->writeStructBegin($class); - foreach ($spec as $fid => $fspec) { - $var = $fspec['var']; - if ($this->$var !== null) { - $ftype = $fspec['type']; - $xfer += $output->writeFieldBegin($var, $ftype, $fid); - if (isset(TBase::$tmethod[$ftype])) { - $func = 'write'.TBase::$tmethod[$ftype]; - $xfer += $output->$func($this->$var); - } else { - switch ($ftype) { - case TType::STRUCT: - $xfer += $this->$var->write($output); - break; - case TType::MAP: - $xfer += $this->_writeMap($this->$var, $fspec, $output); - break; - case TType::LST: - $xfer += $this->_writeList($this->$var, $fspec, $output, false); - break; - case TType::SET: - $xfer += $this->_writeList($this->$var, $fspec, $output, true); - break; - } - } - $xfer += $output->writeFieldEnd(); - } - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } -} - -class TApplicationException extends TException { - static $_TSPEC = - array(1 => array('var' => 'message', - 'type' => TType::STRING), - 2 => array('var' => 'code', - 'type' => TType::I32)); - - const UNKNOWN = 0; - const UNKNOWN_METHOD = 1; - const INVALID_MESSAGE_TYPE = 2; - const WRONG_METHOD_NAME = 3; - const BAD_SEQUENCE_ID = 4; - const MISSING_RESULT = 5; - - function __construct($message=null, $code=0) { - parent::__construct($message, $code); - } - - public function read($output) { - return $this->_read('TApplicationException', self::$_TSPEC, $output); - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('TApplicationException'); - if ($message = $this->getMessage()) { - $xfer += $output->writeFieldBegin('message', TType::STRING, 1); - $xfer += $output->writeString($message); - $xfer += $output->writeFieldEnd(); - } - if ($code = $this->getCode()) { - $xfer += $output->writeFieldBegin('type', TType::I32, 2); - $xfer += $output->writeI32($code); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } -} - -/** - * Set global THRIFT ROOT automatically via inclusion here - */ -if (!isset($GLOBALS['THRIFT_ROOT'])) { - $GLOBALS['THRIFT_ROOT'] = dirname(__FILE__); -} -include_once $GLOBALS['THRIFT_ROOT'].'/protocol/TProtocol.php'; -include_once $GLOBALS['THRIFT_ROOT'].'/transport/TTransport.php'; - -?> diff --git a/include/Thrift/autoload.php b/include/Thrift/autoload.php deleted file mode 100644 index 3a35545d..00000000 --- a/include/Thrift/autoload.php +++ /dev/null @@ -1,51 +0,0 @@ -input_ = $input; - $this->output_ = $output ? $output : $input; - } - - public function getName() - { - $this->send_getName(); - return $this->recv_getName(); - } - - public function send_getName() - { - $args = new FacebookService_getName_args(); - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'getName', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('getName', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_getName() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'FacebookService_getName_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new FacebookService_getName_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - if ($result->success !== null) { - return $result->success; - } - throw new Exception("getName failed: unknown result"); - } - - public function getVersion() - { - $this->send_getVersion(); - return $this->recv_getVersion(); - } - - public function send_getVersion() - { - $args = new FacebookService_getVersion_args(); - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'getVersion', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('getVersion', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_getVersion() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'FacebookService_getVersion_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new FacebookService_getVersion_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - if ($result->success !== null) { - return $result->success; - } - throw new Exception("getVersion failed: unknown result"); - } - - public function getStatus() - { - $this->send_getStatus(); - return $this->recv_getStatus(); - } - - public function send_getStatus() - { - $args = new FacebookService_getStatus_args(); - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'getStatus', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('getStatus', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_getStatus() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'FacebookService_getStatus_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new FacebookService_getStatus_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - if ($result->success !== null) { - return $result->success; - } - throw new Exception("getStatus failed: unknown result"); - } - - public function getStatusDetails() - { - $this->send_getStatusDetails(); - return $this->recv_getStatusDetails(); - } - - public function send_getStatusDetails() - { - $args = new FacebookService_getStatusDetails_args(); - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'getStatusDetails', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('getStatusDetails', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_getStatusDetails() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'FacebookService_getStatusDetails_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new FacebookService_getStatusDetails_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - if ($result->success !== null) { - return $result->success; - } - throw new Exception("getStatusDetails failed: unknown result"); - } - - public function getCounters() - { - $this->send_getCounters(); - return $this->recv_getCounters(); - } - - public function send_getCounters() - { - $args = new FacebookService_getCounters_args(); - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'getCounters', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('getCounters', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_getCounters() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'FacebookService_getCounters_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new FacebookService_getCounters_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - if ($result->success !== null) { - return $result->success; - } - throw new Exception("getCounters failed: unknown result"); - } - - public function getCounter($key) - { - $this->send_getCounter($key); - return $this->recv_getCounter(); - } - - public function send_getCounter($key) - { - $args = new FacebookService_getCounter_args(); - $args->key = $key; - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'getCounter', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('getCounter', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_getCounter() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'FacebookService_getCounter_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new FacebookService_getCounter_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - if ($result->success !== null) { - return $result->success; - } - throw new Exception("getCounter failed: unknown result"); - } - - public function setOption($key, $value) - { - $this->send_setOption($key, $value); - $this->recv_setOption(); - } - - public function send_setOption($key, $value) - { - $args = new FacebookService_setOption_args(); - $args->key = $key; - $args->value = $value; - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'setOption', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('setOption', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_setOption() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'FacebookService_setOption_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new FacebookService_setOption_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - return; - } - - public function getOption($key) - { - $this->send_getOption($key); - return $this->recv_getOption(); - } - - public function send_getOption($key) - { - $args = new FacebookService_getOption_args(); - $args->key = $key; - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'getOption', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('getOption', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_getOption() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'FacebookService_getOption_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new FacebookService_getOption_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - if ($result->success !== null) { - return $result->success; - } - throw new Exception("getOption failed: unknown result"); - } - - public function getOptions() - { - $this->send_getOptions(); - return $this->recv_getOptions(); - } - - public function send_getOptions() - { - $args = new FacebookService_getOptions_args(); - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'getOptions', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('getOptions', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_getOptions() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'FacebookService_getOptions_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new FacebookService_getOptions_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - if ($result->success !== null) { - return $result->success; - } - throw new Exception("getOptions failed: unknown result"); - } - - public function getCpuProfile($profileDurationInSec) - { - $this->send_getCpuProfile($profileDurationInSec); - return $this->recv_getCpuProfile(); - } - - public function send_getCpuProfile($profileDurationInSec) - { - $args = new FacebookService_getCpuProfile_args(); - $args->profileDurationInSec = $profileDurationInSec; - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'getCpuProfile', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('getCpuProfile', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_getCpuProfile() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'FacebookService_getCpuProfile_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new FacebookService_getCpuProfile_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - if ($result->success !== null) { - return $result->success; - } - throw new Exception("getCpuProfile failed: unknown result"); - } - - public function aliveSince() - { - $this->send_aliveSince(); - return $this->recv_aliveSince(); - } - - public function send_aliveSince() - { - $args = new FacebookService_aliveSince_args(); - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'aliveSince', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('aliveSince', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - - public function recv_aliveSince() - { - $bin_accel = ($this->input_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_read_binary'); - if ($bin_accel) $result = thrift_protocol_read_binary($this->input_, 'FacebookService_aliveSince_result', $this->input_->isStrictRead()); - else - { - $rseqid = 0; - $fname = null; - $mtype = 0; - - $this->input_->readMessageBegin($fname, $mtype, $rseqid); - if ($mtype == TMessageType::EXCEPTION) { - $x = new TApplicationException(); - $x->read($this->input_); - $this->input_->readMessageEnd(); - throw $x; - } - $result = new FacebookService_aliveSince_result(); - $result->read($this->input_); - $this->input_->readMessageEnd(); - } - if ($result->success !== null) { - return $result->success; - } - throw new Exception("aliveSince failed: unknown result"); - } - - public function reinitialize() - { - $this->send_reinitialize(); - } - - public function send_reinitialize() - { - $args = new FacebookService_reinitialize_args(); - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'reinitialize', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('reinitialize', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } - public function shutdown() - { - $this->send_shutdown(); - } - - public function send_shutdown() - { - $args = new FacebookService_shutdown_args(); - $bin_accel = ($this->output_ instanceof TProtocol::$TBINARYPROTOCOLACCELERATED) && function_exists('thrift_protocol_write_binary'); - if ($bin_accel) - { - thrift_protocol_write_binary($this->output_, 'shutdown', TMessageType::CALL, $args, $this->seqid_, $this->output_->isStrictWrite()); - } - else - { - $this->output_->writeMessageBegin('shutdown', TMessageType::CALL, $this->seqid_); - $args->write($this->output_); - $this->output_->writeMessageEnd(); - $this->output_->getTransport()->flush(); - } - } -} - -// HELPER FUNCTIONS AND STRUCTURES - -class FacebookService_getName_args { - static $_TSPEC; - - - public function __construct() { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - ); - } - } - - public function getName() { - return 'FacebookService_getName_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getName_args'); - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getName_result { - static $_TSPEC; - - public $success = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 0 => array( - 'var' => 'success', - 'type' => TType::STRING, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['success'])) { - $this->success = $vals['success']; - } - } - } - - public function getName() { - return 'FacebookService_getName_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 0: - if ($ftype == TType::STRING) { - $xfer += $input->readString($this->success); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getName_result'); - if ($this->success !== null) { - $xfer += $output->writeFieldBegin('success', TType::STRING, 0); - $xfer += $output->writeString($this->success); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getVersion_args { - static $_TSPEC; - - - public function __construct() { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - ); - } - } - - public function getName() { - return 'FacebookService_getVersion_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getVersion_args'); - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getVersion_result { - static $_TSPEC; - - public $success = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 0 => array( - 'var' => 'success', - 'type' => TType::STRING, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['success'])) { - $this->success = $vals['success']; - } - } - } - - public function getName() { - return 'FacebookService_getVersion_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 0: - if ($ftype == TType::STRING) { - $xfer += $input->readString($this->success); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getVersion_result'); - if ($this->success !== null) { - $xfer += $output->writeFieldBegin('success', TType::STRING, 0); - $xfer += $output->writeString($this->success); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getStatus_args { - static $_TSPEC; - - - public function __construct() { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - ); - } - } - - public function getName() { - return 'FacebookService_getStatus_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getStatus_args'); - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getStatus_result { - static $_TSPEC; - - public $success = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 0 => array( - 'var' => 'success', - 'type' => TType::I32, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['success'])) { - $this->success = $vals['success']; - } - } - } - - public function getName() { - return 'FacebookService_getStatus_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 0: - if ($ftype == TType::I32) { - $xfer += $input->readI32($this->success); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getStatus_result'); - if ($this->success !== null) { - $xfer += $output->writeFieldBegin('success', TType::I32, 0); - $xfer += $output->writeI32($this->success); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getStatusDetails_args { - static $_TSPEC; - - - public function __construct() { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - ); - } - } - - public function getName() { - return 'FacebookService_getStatusDetails_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getStatusDetails_args'); - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getStatusDetails_result { - static $_TSPEC; - - public $success = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 0 => array( - 'var' => 'success', - 'type' => TType::STRING, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['success'])) { - $this->success = $vals['success']; - } - } - } - - public function getName() { - return 'FacebookService_getStatusDetails_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 0: - if ($ftype == TType::STRING) { - $xfer += $input->readString($this->success); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getStatusDetails_result'); - if ($this->success !== null) { - $xfer += $output->writeFieldBegin('success', TType::STRING, 0); - $xfer += $output->writeString($this->success); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getCounters_args { - static $_TSPEC; - - - public function __construct() { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - ); - } - } - - public function getName() { - return 'FacebookService_getCounters_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getCounters_args'); - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getCounters_result { - static $_TSPEC; - - public $success = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 0 => array( - 'var' => 'success', - 'type' => TType::MAP, - 'ktype' => TType::STRING, - 'vtype' => TType::I64, - 'key' => array( - 'type' => TType::STRING, - ), - 'val' => array( - 'type' => TType::I64, - ), - ), - ); - } - if (is_array($vals)) { - if (isset($vals['success'])) { - $this->success = $vals['success']; - } - } - } - - public function getName() { - return 'FacebookService_getCounters_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 0: - if ($ftype == TType::MAP) { - $this->success = array(); - $_size0 = 0; - $_ktype1 = 0; - $_vtype2 = 0; - $xfer += $input->readMapBegin($_ktype1, $_vtype2, $_size0); - for ($_i4 = 0; $_i4 < $_size0; ++$_i4) - { - $key5 = ''; - $val6 = 0; - $xfer += $input->readString($key5); - $xfer += $input->readI64($val6); - $this->success[$key5] = $val6; - } - $xfer += $input->readMapEnd(); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getCounters_result'); - if ($this->success !== null) { - if (!is_array($this->success)) { - throw new TProtocolException('Bad type in structure.', TProtocolException::INVALID_DATA); - } - $xfer += $output->writeFieldBegin('success', TType::MAP, 0); - { - $output->writeMapBegin(TType::STRING, TType::I64, count($this->success)); - { - foreach ($this->success as $kiter7 => $viter8) - { - $xfer += $output->writeString($kiter7); - $xfer += $output->writeI64($viter8); - } - } - $output->writeMapEnd(); - } - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getCounter_args { - static $_TSPEC; - - public $key = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 1 => array( - 'var' => 'key', - 'type' => TType::STRING, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['key'])) { - $this->key = $vals['key']; - } - } - } - - public function getName() { - return 'FacebookService_getCounter_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 1: - if ($ftype == TType::STRING) { - $xfer += $input->readString($this->key); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getCounter_args'); - if ($this->key !== null) { - $xfer += $output->writeFieldBegin('key', TType::STRING, 1); - $xfer += $output->writeString($this->key); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getCounter_result { - static $_TSPEC; - - public $success = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 0 => array( - 'var' => 'success', - 'type' => TType::I64, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['success'])) { - $this->success = $vals['success']; - } - } - } - - public function getName() { - return 'FacebookService_getCounter_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 0: - if ($ftype == TType::I64) { - $xfer += $input->readI64($this->success); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getCounter_result'); - if ($this->success !== null) { - $xfer += $output->writeFieldBegin('success', TType::I64, 0); - $xfer += $output->writeI64($this->success); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_setOption_args { - static $_TSPEC; - - public $key = null; - public $value = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 1 => array( - 'var' => 'key', - 'type' => TType::STRING, - ), - 2 => array( - 'var' => 'value', - 'type' => TType::STRING, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['key'])) { - $this->key = $vals['key']; - } - if (isset($vals['value'])) { - $this->value = $vals['value']; - } - } - } - - public function getName() { - return 'FacebookService_setOption_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 1: - if ($ftype == TType::STRING) { - $xfer += $input->readString($this->key); - } else { - $xfer += $input->skip($ftype); - } - break; - case 2: - if ($ftype == TType::STRING) { - $xfer += $input->readString($this->value); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_setOption_args'); - if ($this->key !== null) { - $xfer += $output->writeFieldBegin('key', TType::STRING, 1); - $xfer += $output->writeString($this->key); - $xfer += $output->writeFieldEnd(); - } - if ($this->value !== null) { - $xfer += $output->writeFieldBegin('value', TType::STRING, 2); - $xfer += $output->writeString($this->value); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_setOption_result { - static $_TSPEC; - - - public function __construct() { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - ); - } - } - - public function getName() { - return 'FacebookService_setOption_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_setOption_result'); - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getOption_args { - static $_TSPEC; - - public $key = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 1 => array( - 'var' => 'key', - 'type' => TType::STRING, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['key'])) { - $this->key = $vals['key']; - } - } - } - - public function getName() { - return 'FacebookService_getOption_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 1: - if ($ftype == TType::STRING) { - $xfer += $input->readString($this->key); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getOption_args'); - if ($this->key !== null) { - $xfer += $output->writeFieldBegin('key', TType::STRING, 1); - $xfer += $output->writeString($this->key); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getOption_result { - static $_TSPEC; - - public $success = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 0 => array( - 'var' => 'success', - 'type' => TType::STRING, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['success'])) { - $this->success = $vals['success']; - } - } - } - - public function getName() { - return 'FacebookService_getOption_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 0: - if ($ftype == TType::STRING) { - $xfer += $input->readString($this->success); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getOption_result'); - if ($this->success !== null) { - $xfer += $output->writeFieldBegin('success', TType::STRING, 0); - $xfer += $output->writeString($this->success); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getOptions_args { - static $_TSPEC; - - - public function __construct() { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - ); - } - } - - public function getName() { - return 'FacebookService_getOptions_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getOptions_args'); - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getOptions_result { - static $_TSPEC; - - public $success = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 0 => array( - 'var' => 'success', - 'type' => TType::MAP, - 'ktype' => TType::STRING, - 'vtype' => TType::STRING, - 'key' => array( - 'type' => TType::STRING, - ), - 'val' => array( - 'type' => TType::STRING, - ), - ), - ); - } - if (is_array($vals)) { - if (isset($vals['success'])) { - $this->success = $vals['success']; - } - } - } - - public function getName() { - return 'FacebookService_getOptions_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 0: - if ($ftype == TType::MAP) { - $this->success = array(); - $_size9 = 0; - $_ktype10 = 0; - $_vtype11 = 0; - $xfer += $input->readMapBegin($_ktype10, $_vtype11, $_size9); - for ($_i13 = 0; $_i13 < $_size9; ++$_i13) - { - $key14 = ''; - $val15 = ''; - $xfer += $input->readString($key14); - $xfer += $input->readString($val15); - $this->success[$key14] = $val15; - } - $xfer += $input->readMapEnd(); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getOptions_result'); - if ($this->success !== null) { - if (!is_array($this->success)) { - throw new TProtocolException('Bad type in structure.', TProtocolException::INVALID_DATA); - } - $xfer += $output->writeFieldBegin('success', TType::MAP, 0); - { - $output->writeMapBegin(TType::STRING, TType::STRING, count($this->success)); - { - foreach ($this->success as $kiter16 => $viter17) - { - $xfer += $output->writeString($kiter16); - $xfer += $output->writeString($viter17); - } - } - $output->writeMapEnd(); - } - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getCpuProfile_args { - static $_TSPEC; - - public $profileDurationInSec = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 1 => array( - 'var' => 'profileDurationInSec', - 'type' => TType::I32, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['profileDurationInSec'])) { - $this->profileDurationInSec = $vals['profileDurationInSec']; - } - } - } - - public function getName() { - return 'FacebookService_getCpuProfile_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 1: - if ($ftype == TType::I32) { - $xfer += $input->readI32($this->profileDurationInSec); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getCpuProfile_args'); - if ($this->profileDurationInSec !== null) { - $xfer += $output->writeFieldBegin('profileDurationInSec', TType::I32, 1); - $xfer += $output->writeI32($this->profileDurationInSec); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_getCpuProfile_result { - static $_TSPEC; - - public $success = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 0 => array( - 'var' => 'success', - 'type' => TType::STRING, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['success'])) { - $this->success = $vals['success']; - } - } - } - - public function getName() { - return 'FacebookService_getCpuProfile_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 0: - if ($ftype == TType::STRING) { - $xfer += $input->readString($this->success); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_getCpuProfile_result'); - if ($this->success !== null) { - $xfer += $output->writeFieldBegin('success', TType::STRING, 0); - $xfer += $output->writeString($this->success); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_aliveSince_args { - static $_TSPEC; - - - public function __construct() { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - ); - } - } - - public function getName() { - return 'FacebookService_aliveSince_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_aliveSince_args'); - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_aliveSince_result { - static $_TSPEC; - - public $success = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 0 => array( - 'var' => 'success', - 'type' => TType::I64, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['success'])) { - $this->success = $vals['success']; - } - } - } - - public function getName() { - return 'FacebookService_aliveSince_result'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 0: - if ($ftype == TType::I64) { - $xfer += $input->readI64($this->success); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_aliveSince_result'); - if ($this->success !== null) { - $xfer += $output->writeFieldBegin('success', TType::I64, 0); - $xfer += $output->writeI64($this->success); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_reinitialize_args { - static $_TSPEC; - - - public function __construct() { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - ); - } - } - - public function getName() { - return 'FacebookService_reinitialize_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_reinitialize_args'); - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -class FacebookService_shutdown_args { - static $_TSPEC; - - - public function __construct() { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - ); - } - } - - public function getName() { - return 'FacebookService_shutdown_args'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('FacebookService_shutdown_args'); - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -?> diff --git a/include/Thrift/packages/fb303/fb303_types.php b/include/Thrift/packages/fb303/fb303_types.php deleted file mode 100644 index d5adca31..00000000 --- a/include/Thrift/packages/fb303/fb303_types.php +++ /dev/null @@ -1,36 +0,0 @@ - 0, - 'STARTING' => 1, - 'ALIVE' => 2, - 'STOPPING' => 3, - 'STOPPED' => 4, - 'WARNING' => 5, -); - -final class fb_status { - const DEAD = 0; - const STARTING = 1; - const ALIVE = 2; - const STOPPING = 3; - const STOPPED = 4; - const WARNING = 5; - static public $__names = array( - 0 => 'DEAD', - 1 => 'STARTING', - 2 => 'ALIVE', - 3 => 'STOPPING', - 4 => 'STOPPED', - 5 => 'WARNING', - ); -} - -?> diff --git a/include/Thrift/packages/scribe/scribe_types.php b/include/Thrift/packages/scribe/scribe_types.php deleted file mode 100644 index 186ceb85..00000000 --- a/include/Thrift/packages/scribe/scribe_types.php +++ /dev/null @@ -1,117 +0,0 @@ - 0, - 'TRY_LATER' => 1, -); - -final class ResultCode { - const OK = 0; - const TRY_LATER = 1; - static public $__names = array( - 0 => 'OK', - 1 => 'TRY_LATER', - ); -} - -class LogEntry { - static $_TSPEC; - - public $category = null; - public $message = null; - - public function __construct($vals=null) { - if (!isset(self::$_TSPEC)) { - self::$_TSPEC = array( - 1 => array( - 'var' => 'category', - 'type' => TType::STRING, - ), - 2 => array( - 'var' => 'message', - 'type' => TType::STRING, - ), - ); - } - if (is_array($vals)) { - if (isset($vals['category'])) { - $this->category = $vals['category']; - } - if (isset($vals['message'])) { - $this->message = $vals['message']; - } - } - } - - public function getName() { - return 'LogEntry'; - } - - public function read($input) - { - $xfer = 0; - $fname = null; - $ftype = 0; - $fid = 0; - $xfer += $input->readStructBegin($fname); - while (true) - { - $xfer += $input->readFieldBegin($fname, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - switch ($fid) - { - case 1: - if ($ftype == TType::STRING) { - $xfer += $input->readString($this->category); - } else { - $xfer += $input->skip($ftype); - } - break; - case 2: - if ($ftype == TType::STRING) { - $xfer += $input->readString($this->message); - } else { - $xfer += $input->skip($ftype); - } - break; - default: - $xfer += $input->skip($ftype); - break; - } - $xfer += $input->readFieldEnd(); - } - $xfer += $input->readStructEnd(); - return $xfer; - } - - public function write($output) { - $xfer = 0; - $xfer += $output->writeStructBegin('LogEntry'); - if ($this->category !== null) { - $xfer += $output->writeFieldBegin('category', TType::STRING, 1); - $xfer += $output->writeString($this->category); - $xfer += $output->writeFieldEnd(); - } - if ($this->message !== null) { - $xfer += $output->writeFieldBegin('message', TType::STRING, 2); - $xfer += $output->writeString($this->message); - $xfer += $output->writeFieldEnd(); - } - $xfer += $output->writeFieldStop(); - $xfer += $output->writeStructEnd(); - return $xfer; - } - -} - -?> diff --git a/include/Thrift/protocol/TBinaryProtocol.php b/include/Thrift/protocol/TBinaryProtocol.php deleted file mode 100644 index 31bbbf9d..00000000 --- a/include/Thrift/protocol/TBinaryProtocol.php +++ /dev/null @@ -1,431 +0,0 @@ -strictRead_ = $strictRead; - $this->strictWrite_ = $strictWrite; - } - - public function writeMessageBegin($name, $type, $seqid) { - if ($this->strictWrite_) { - $version = self::VERSION_1 | $type; - return - $this->writeI32($version) + - $this->writeString($name) + - $this->writeI32($seqid); - } else { - return - $this->writeString($name) + - $this->writeByte($type) + - $this->writeI32($seqid); - } - } - - public function writeMessageEnd() { - return 0; - } - - public function writeStructBegin($name) { - return 0; - } - - public function writeStructEnd() { - return 0; - } - - public function writeFieldBegin($fieldName, $fieldType, $fieldId) { - return - $this->writeByte($fieldType) + - $this->writeI16($fieldId); - } - - public function writeFieldEnd() { - return 0; - } - - public function writeFieldStop() { - return - $this->writeByte(TType::STOP); - } - - public function writeMapBegin($keyType, $valType, $size) { - return - $this->writeByte($keyType) + - $this->writeByte($valType) + - $this->writeI32($size); - } - - public function writeMapEnd() { - return 0; - } - - public function writeListBegin($elemType, $size) { - return - $this->writeByte($elemType) + - $this->writeI32($size); - } - - public function writeListEnd() { - return 0; - } - - public function writeSetBegin($elemType, $size) { - return - $this->writeByte($elemType) + - $this->writeI32($size); - } - - public function writeSetEnd() { - return 0; - } - - public function writeBool($value) { - $data = pack('c', $value ? 1 : 0); - $this->trans_->write($data, 1); - return 1; - } - - public function writeByte($value) { - $data = pack('c', $value); - $this->trans_->write($data, 1); - return 1; - } - - public function writeI16($value) { - $data = pack('n', $value); - $this->trans_->write($data, 2); - return 2; - } - - public function writeI32($value) { - $data = pack('N', $value); - $this->trans_->write($data, 4); - return 4; - } - - public function writeI64($value) { - // If we are on a 32bit architecture we have to explicitly deal with - // 64-bit twos-complement arithmetic since PHP wants to treat all ints - // as signed and any int over 2^31 - 1 as a float - if (PHP_INT_SIZE == 4) { - $neg = $value < 0; - - if ($neg) { - $value *= -1; - } - - $hi = (int)($value / 4294967296); - $lo = (int)$value; - - if ($neg) { - $hi = ~$hi; - $lo = ~$lo; - if (($lo & (int)0xffffffff) == (int)0xffffffff) { - $lo = 0; - $hi++; - } else { - $lo++; - } - } - $data = pack('N2', $hi, $lo); - - } else { - $hi = $value >> 32; - $lo = $value & 0xFFFFFFFF; - $data = pack('N2', $hi, $lo); - } - - $this->trans_->write($data, 8); - return 8; - } - - public function writeDouble($value) { - $data = pack('d', $value); - $this->trans_->write(strrev($data), 8); - return 8; - } - - public function writeString($value) { - $len = strlen($value); - $result = $this->writeI32($len); - if ($len) { - $this->trans_->write($value, $len); - } - return $result + $len; - } - - public function readMessageBegin(&$name, &$type, &$seqid) { - $result = $this->readI32($sz); - if ($sz < 0) { - $version = (int) ($sz & self::VERSION_MASK); - if ($version != (int) self::VERSION_1) { - throw new TProtocolException('Bad version identifier: '.$sz, TProtocolException::BAD_VERSION); - } - $type = $sz & 0x000000ff; - $result += - $this->readString($name) + - $this->readI32($seqid); - } else { - if ($this->strictRead_) { - throw new TProtocolException('No version identifier, old protocol client?', TProtocolException::BAD_VERSION); - } else { - // Handle pre-versioned input - $name = $this->trans_->readAll($sz); - $result += - $sz + - $this->readByte($type) + - $this->readI32($seqid); - } - } - return $result; - } - - public function readMessageEnd() { - return 0; - } - - public function readStructBegin(&$name) { - $name = ''; - return 0; - } - - public function readStructEnd() { - return 0; - } - - public function readFieldBegin(&$name, &$fieldType, &$fieldId) { - $result = $this->readByte($fieldType); - if ($fieldType == TType::STOP) { - $fieldId = 0; - return $result; - } - $result += $this->readI16($fieldId); - return $result; - } - - public function readFieldEnd() { - return 0; - } - - public function readMapBegin(&$keyType, &$valType, &$size) { - return - $this->readByte($keyType) + - $this->readByte($valType) + - $this->readI32($size); - } - - public function readMapEnd() { - return 0; - } - - public function readListBegin(&$elemType, &$size) { - return - $this->readByte($elemType) + - $this->readI32($size); - } - - public function readListEnd() { - return 0; - } - - public function readSetBegin(&$elemType, &$size) { - return - $this->readByte($elemType) + - $this->readI32($size); - } - - public function readSetEnd() { - return 0; - } - - public function readBool(&$value) { - $data = $this->trans_->readAll(1); - $arr = unpack('c', $data); - $value = $arr[1] == 1; - return 1; - } - - public function readByte(&$value) { - $data = $this->trans_->readAll(1); - $arr = unpack('c', $data); - $value = $arr[1]; - return 1; - } - - public function readI16(&$value) { - $data = $this->trans_->readAll(2); - $arr = unpack('n', $data); - $value = $arr[1]; - if ($value > 0x7fff) { - $value = 0 - (($value - 1) ^ 0xffff); - } - return 2; - } - - public function readI32(&$value) { - $data = $this->trans_->readAll(4); - $arr = unpack('N', $data); - $value = $arr[1]; - if ($value > 0x7fffffff) { - $value = 0 - (($value - 1) ^ 0xffffffff); - } - return 4; - } - - public function readI64(&$value) { - $data = $this->trans_->readAll(8); - - $arr = unpack('N2', $data); - - // If we are on a 32bit architecture we have to explicitly deal with - // 64-bit twos-complement arithmetic since PHP wants to treat all ints - // as signed and any int over 2^31 - 1 as a float - if (PHP_INT_SIZE == 4) { - - $hi = $arr[1]; - $lo = $arr[2]; - $isNeg = $hi < 0; - - // Check for a negative - if ($isNeg) { - $hi = ~$hi & (int)0xffffffff; - $lo = ~$lo & (int)0xffffffff; - - if ($lo == (int)0xffffffff) { - $hi++; - $lo = 0; - } else { - $lo++; - } - } - - // Force 32bit words in excess of 2G to pe positive - we deal wigh sign - // explicitly below - - if ($hi & (int)0x80000000) { - $hi &= (int)0x7fffffff; - $hi += 0x80000000; - } - - if ($lo & (int)0x80000000) { - $lo &= (int)0x7fffffff; - $lo += 0x80000000; - } - - $value = $hi * 4294967296 + $lo; - - if ($isNeg) { - $value = 0 - $value; - } - } else { - - // Upcast negatives in LSB bit - if ($arr[2] & 0x80000000) { - $arr[2] = $arr[2] & 0xffffffff; - } - - // Check for a negative - if ($arr[1] & 0x80000000) { - $arr[1] = $arr[1] & 0xffffffff; - $arr[1] = $arr[1] ^ 0xffffffff; - $arr[2] = $arr[2] ^ 0xffffffff; - $value = 0 - $arr[1]*4294967296 - $arr[2] - 1; - } else { - $value = $arr[1]*4294967296 + $arr[2]; - } - } - - return 8; - } - - public function readDouble(&$value) { - $data = strrev($this->trans_->readAll(8)); - $arr = unpack('d', $data); - $value = $arr[1]; - return 8; - } - - public function readString(&$value) { - $result = $this->readI32($len); - if ($len) { - $value = $this->trans_->readAll($len); - } else { - $value = ''; - } - return $result + $len; - } -} - -/** - * Binary Protocol Factory - */ -class TBinaryProtocolFactory implements TProtocolFactory { - private $strictRead_ = false; - private $strictWrite_ = false; - - public function __construct($strictRead=false, $strictWrite=false) { - $this->strictRead_ = $strictRead; - $this->strictWrite_ = $strictWrite; - } - - public function getProtocol($trans) { - return new TBinaryProtocol($trans, $this->strictRead, $this->strictWrite); - } -} - -/** - * Accelerated binary protocol: used in conjunction with the thrift_protocol - * extension for faster deserialization - */ -class TBinaryProtocolAccelerated extends TBinaryProtocol { - public function __construct($trans, $strictRead=false, $strictWrite=true) { - // If the transport doesn't implement putBack, wrap it in a - // TBufferedTransport (which does) - if (!method_exists($trans, 'putBack')) { - $trans = new TBufferedTransport($trans); - } - parent::__construct($trans, $strictRead, $strictWrite); - } - public function isStrictRead() { - return $this->strictRead_; - } - public function isStrictWrite() { - return $this->strictWrite_; - } -} - -?> diff --git a/include/Thrift/protocol/TProtocol.php b/include/Thrift/protocol/TProtocol.php deleted file mode 100644 index e9ff41a3..00000000 --- a/include/Thrift/protocol/TProtocol.php +++ /dev/null @@ -1,377 +0,0 @@ -trans_ = $trans; - } - - /** - * Accessor for transport - * - * @return TTransport - */ - public function getTransport() { - return $this->trans_; - } - - /** - * Writes the message header - * - * @param string $name Function name - * @param int $type message type TMessageType::CALL or TMessageType::REPLY - * @param int $seqid The sequence id of this message - */ - public abstract function writeMessageBegin($name, $type, $seqid); - - /** - * Close the message - */ - public abstract function writeMessageEnd(); - - /** - * Writes a struct header. - * - * @param string $name Struct name - * @throws TException on write error - * @return int How many bytes written - */ - public abstract function writeStructBegin($name); - - /** - * Close a struct. - * - * @throws TException on write error - * @return int How many bytes written - */ - public abstract function writeStructEnd(); - - /* - * Starts a field. - * - * @param string $name Field name - * @param int $type Field type - * @param int $fid Field id - * @throws TException on write error - * @return int How many bytes written - */ - public abstract function writeFieldBegin($fieldName, $fieldType, $fieldId); - - public abstract function writeFieldEnd(); - - public abstract function writeFieldStop(); - - public abstract function writeMapBegin($keyType, $valType, $size); - - public abstract function writeMapEnd(); - - public abstract function writeListBegin($elemType, $size); - - public abstract function writeListEnd(); - - public abstract function writeSetBegin($elemType, $size); - - public abstract function writeSetEnd(); - - public abstract function writeBool($bool); - - public abstract function writeByte($byte); - - public abstract function writeI16($i16); - - public abstract function writeI32($i32); - - public abstract function writeI64($i64); - - public abstract function writeDouble($dub); - - public abstract function writeString($str); - - /** - * Reads the message header - * - * @param string $name Function name - * @param int $type message type TMessageType::CALL or TMessageType::REPLY - * @parem int $seqid The sequence id of this message - */ - public abstract function readMessageBegin(&$name, &$type, &$seqid); - - /** - * Read the close of message - */ - public abstract function readMessageEnd(); - - public abstract function readStructBegin(&$name); - - public abstract function readStructEnd(); - - public abstract function readFieldBegin(&$name, &$fieldType, &$fieldId); - - public abstract function readFieldEnd(); - - public abstract function readMapBegin(&$keyType, &$valType, &$size); - - public abstract function readMapEnd(); - - public abstract function readListBegin(&$elemType, &$size); - - public abstract function readListEnd(); - - public abstract function readSetBegin(&$elemType, &$size); - - public abstract function readSetEnd(); - - public abstract function readBool(&$bool); - - public abstract function readByte(&$byte); - - public abstract function readI16(&$i16); - - public abstract function readI32(&$i32); - - public abstract function readI64(&$i64); - - public abstract function readDouble(&$dub); - - public abstract function readString(&$str); - - /** - * The skip function is a utility to parse over unrecognized date without - * causing corruption. - * - * @param TType $type What type is it - */ - public function skip($type) { - switch ($type) { - case TType::BOOL: - return $this->readBool($bool); - case TType::BYTE: - return $this->readByte($byte); - case TType::I16: - return $this->readI16($i16); - case TType::I32: - return $this->readI32($i32); - case TType::I64: - return $this->readI64($i64); - case TType::DOUBLE: - return $this->readDouble($dub); - case TType::STRING: - return $this->readString($str); - case TType::STRUCT: - { - $result = $this->readStructBegin($name); - while (true) { - $result += $this->readFieldBegin($name, $ftype, $fid); - if ($ftype == TType::STOP) { - break; - } - $result += $this->skip($ftype); - $result += $this->readFieldEnd(); - } - $result += $this->readStructEnd(); - return $result; - } - case TType::MAP: - { - $result = $this->readMapBegin($keyType, $valType, $size); - for ($i = 0; $i < $size; $i++) { - $result += $this->skip($keyType); - $result += $this->skip($valType); - } - $result += $this->readMapEnd(); - return $result; - } - case TType::SET: - { - $result = $this->readSetBegin($elemType, $size); - for ($i = 0; $i < $size; $i++) { - $result += $this->skip($elemType); - } - $result += $this->readSetEnd(); - return $result; - } - case TType::LST: - { - $result = $this->readListBegin($elemType, $size); - for ($i = 0; $i < $size; $i++) { - $result += $this->skip($elemType); - } - $result += $this->readListEnd(); - return $result; - } - default: - return 0; - } - } - - /** - * Utility for skipping binary data - * - * @param TTransport $itrans TTransport object - * @param int $type Field type - */ - public static function skipBinary($itrans, $type) { - switch ($type) { - case TType::BOOL: - return $itrans->readAll(1); - case TType::BYTE: - return $itrans->readAll(1); - case TType::I16: - return $itrans->readAll(2); - case TType::I32: - return $itrans->readAll(4); - case TType::I64: - return $itrans->readAll(8); - case TType::DOUBLE: - return $itrans->readAll(8); - case TType::STRING: - $len = unpack('N', $itrans->readAll(4)); - $len = $len[1]; - if ($len > 0x7fffffff) { - $len = 0 - (($len - 1) ^ 0xffffffff); - } - return 4 + $itrans->readAll($len); - case TType::STRUCT: - { - $result = 0; - while (true) { - $ftype = 0; - $fid = 0; - $data = $itrans->readAll(1); - $arr = unpack('c', $data); - $ftype = $arr[1]; - if ($ftype == TType::STOP) { - break; - } - // I16 field id - $result += $itrans->readAll(2); - $result += self::skipBinary($itrans, $ftype); - } - return $result; - } - case TType::MAP: - { - // Ktype - $data = $itrans->readAll(1); - $arr = unpack('c', $data); - $ktype = $arr[1]; - // Vtype - $data = $itrans->readAll(1); - $arr = unpack('c', $data); - $vtype = $arr[1]; - // Size - $data = $itrans->readAll(4); - $arr = unpack('N', $data); - $size = $arr[1]; - if ($size > 0x7fffffff) { - $size = 0 - (($size - 1) ^ 0xffffffff); - } - $result = 6; - for ($i = 0; $i < $size; $i++) { - $result += self::skipBinary($itrans, $ktype); - $result += self::skipBinary($itrans, $vtype); - } - return $result; - } - case TType::SET: - case TType::LST: - { - // Vtype - $data = $itrans->readAll(1); - $arr = unpack('c', $data); - $vtype = $arr[1]; - // Size - $data = $itrans->readAll(4); - $arr = unpack('N', $data); - $size = $arr[1]; - if ($size > 0x7fffffff) { - $size = 0 - (($size - 1) ^ 0xffffffff); - } - $result = 5; - for ($i = 0; $i < $size; $i++) { - $result += self::skipBinary($itrans, $vtype); - } - return $result; - } - default: - return 0; - } - } -} - -/** - * Protocol factory creates protocol objects from transports - */ -interface TProtocolFactory { - /** - * Build a protocol from the base transport - * - * @return TProtcol protocol - */ - public function getProtocol($trans); -} - - -?> diff --git a/include/Thrift/transport/TBufferedTransport.php b/include/Thrift/transport/TBufferedTransport.php deleted file mode 100644 index cfae767e..00000000 --- a/include/Thrift/transport/TBufferedTransport.php +++ /dev/null @@ -1,163 +0,0 @@ -transport_ = $transport; - $this->rBufSize_ = $rBufSize; - $this->wBufSize_ = $wBufSize; - } - - /** - * The underlying transport - * - * @var TTransport - */ - protected $transport_ = null; - - /** - * The receive buffer size - * - * @var int - */ - protected $rBufSize_ = 512; - - /** - * The write buffer size - * - * @var int - */ - protected $wBufSize_ = 512; - - /** - * The write buffer. - * - * @var string - */ - protected $wBuf_ = ''; - - /** - * The read buffer. - * - * @var string - */ - protected $rBuf_ = ''; - - public function isOpen() { - return $this->transport_->isOpen(); - } - - public function open() { - $this->transport_->open(); - } - - public function close() { - $this->transport_->close(); - } - - public function putBack($data) { - if (strlen($this->rBuf_) === 0) { - $this->rBuf_ = $data; - } else { - $this->rBuf_ = ($data . $this->rBuf_); - } - } - - /** - * The reason that we customize readAll here is that the majority of PHP - * streams are already internally buffered by PHP. The socket stream, for - * example, buffers internally and blocks if you call read with $len greater - * than the amount of data available, unlike recv() in C. - * - * Therefore, use the readAll method of the wrapped transport inside - * the buffered readAll. - */ - public function readAll($len) { - $have = strlen($this->rBuf_); - if ($have == 0) { - $data = $this->transport_->readAll($len); - } else if ($have < $len) { - $data = $this->rBuf_; - $this->rBuf_ = ''; - $data .= $this->transport_->readAll($len - $have); - } else if ($have == $len) { - $data = $this->rBuf_; - $this->rBuf_ = ''; - } else if ($have > $len) { - $data = substr($this->rBuf_, 0, $len); - $this->rBuf_ = substr($this->rBuf_, $len); - } - return $data; - } - - public function read($len) { - if (strlen($this->rBuf_) === 0) { - $this->rBuf_ = $this->transport_->read($this->rBufSize_); - } - - if (strlen($this->rBuf_) <= $len) { - $ret = $this->rBuf_; - $this->rBuf_ = ''; - return $ret; - } - - $ret = substr($this->rBuf_, 0, $len); - $this->rBuf_ = substr($this->rBuf_, $len); - return $ret; - } - - public function write($buf) { - $this->wBuf_ .= $buf; - if (strlen($this->wBuf_) >= $this->wBufSize_) { - $out = $this->wBuf_; - - // Note that we clear the internal wBuf_ prior to the underlying write - // to ensure we're in a sane state (i.e. internal buffer cleaned) - // if the underlying write throws up an exception - $this->wBuf_ = ''; - $this->transport_->write($out); - } - } - - public function flush() { - if (strlen($this->wBuf_) > 0) { - $this->transport_->write($this->wBuf_); - $this->wBuf_ = ''; - } - $this->transport_->flush(); - } - -} - -?> diff --git a/include/Thrift/transport/TFramedTransport.php b/include/Thrift/transport/TFramedTransport.php deleted file mode 100644 index dc57392f..00000000 --- a/include/Thrift/transport/TFramedTransport.php +++ /dev/null @@ -1,179 +0,0 @@ -transport_ = $transport; - $this->read_ = $read; - $this->write_ = $write; - } - - public function isOpen() { - return $this->transport_->isOpen(); - } - - public function open() { - $this->transport_->open(); - } - - public function close() { - $this->transport_->close(); - } - - /** - * Reads from the buffer. When more data is required reads another entire - * chunk and serves future reads out of that. - * - * @param int $len How much data - */ - public function read($len) { - if (!$this->read_) { - return $this->transport_->read($len); - } - - if (strlen($this->rBuf_) === 0) { - $this->readFrame(); - } - - // Just return full buff - if ($len >= strlen($this->rBuf_)) { - $out = $this->rBuf_; - $this->rBuf_ = null; - return $out; - } - - // Return substr - $out = substr($this->rBuf_, 0, $len); - $this->rBuf_ = substr($this->rBuf_, $len); - return $out; - } - - /** - * Put previously read data back into the buffer - * - * @param string $data data to return - */ - public function putBack($data) { - if (strlen($this->rBuf_) === 0) { - $this->rBuf_ = $data; - } else { - $this->rBuf_ = ($data . $this->rBuf_); - } - } - - /** - * Reads a chunk of data into the internal read buffer. - */ - private function readFrame() { - $buf = $this->transport_->readAll(4); - $val = unpack('N', $buf); - $sz = $val[1]; - - $this->rBuf_ = $this->transport_->readAll($sz); - } - - /** - * Writes some data to the pending output buffer. - * - * @param string $buf The data - * @param int $len Limit of bytes to write - */ - public function write($buf, $len=null) { - if (!$this->write_) { - return $this->transport_->write($buf, $len); - } - - if ($len !== null && $len < strlen($buf)) { - $buf = substr($buf, 0, $len); - } - $this->wBuf_ .= $buf; - } - - /** - * Writes the output buffer to the stream in the format of a 4-byte length - * followed by the actual data. - */ - public function flush() { - if (!$this->write_) { - return $this->transport_->flush(); - } - - $out = pack('N', strlen($this->wBuf_)); - $out .= $this->wBuf_; - - // Note that we clear the internal wBuf_ prior to the underlying write - // to ensure we're in a sane state (i.e. internal buffer cleaned) - // if the underlying write throws up an exception - $this->wBuf_ = ''; - $this->transport_->write($out); - $this->transport_->flush(); - } - -} diff --git a/include/Thrift/transport/THttpClient.php b/include/Thrift/transport/THttpClient.php deleted file mode 100644 index 224d403b..00000000 --- a/include/Thrift/transport/THttpClient.php +++ /dev/null @@ -1,202 +0,0 @@ - 0) && ($uri{0} != '/')) { - $uri = '/'.$uri; - } - $this->scheme_ = $scheme; - $this->host_ = $host; - $this->port_ = $port; - $this->uri_ = $uri; - $this->buf_ = ''; - $this->handle_ = null; - $this->timeout_ = null; - } - - /** - * Set read timeout - * - * @param float $timeout - */ - public function setTimeoutSecs($timeout) { - $this->timeout_ = $timeout; - } - - /** - * Whether this transport is open. - * - * @return boolean true if open - */ - public function isOpen() { - return true; - } - - /** - * Open the transport for reading/writing - * - * @throws TTransportException if cannot open - */ - public function open() {} - - /** - * Close the transport. - */ - public function close() { - if ($this->handle_) { - @fclose($this->handle_); - $this->handle_ = null; - } - } - - /** - * Read some data into the array. - * - * @param int $len How much to read - * @return string The data that has been read - * @throws TTransportException if cannot read any more data - */ - public function read($len) { - $data = @fread($this->handle_, $len); - if ($data === FALSE || $data === '') { - $md = stream_get_meta_data($this->handle_); - if ($md['timed_out']) { - throw new TTransportException('THttpClient: timed out reading '.$len.' bytes from '.$this->host_.':'.$this->port_.'/'.$this->uri_, TTransportException::TIMED_OUT); - } else { - throw new TTransportException('THttpClient: Could not read '.$len.' bytes from '.$this->host_.':'.$this->port_.'/'.$this->uri_, TTransportException::UNKNOWN); - } - } - return $data; - } - - /** - * Writes some data into the pending buffer - * - * @param string $buf The data to write - * @throws TTransportException if writing fails - */ - public function write($buf) { - $this->buf_ .= $buf; - } - - /** - * Opens and sends the actual request over the HTTP connection - * - * @throws TTransportException if a writing error occurs - */ - public function flush() { - // God, PHP really has some esoteric ways of doing simple things. - $host = $this->host_.($this->port_ != 80 ? ':'.$this->port_ : ''); - - $headers = array('Host: '.$host, - 'Accept: application/x-thrift', - 'User-Agent: PHP/THttpClient', - 'Content-Type: application/x-thrift', - 'Content-Length: '.strlen($this->buf_)); - - $options = array('method' => 'POST', - 'header' => implode("\r\n", $headers), - 'max_redirects' => 1, - 'content' => $this->buf_); - if ($this->timeout_ > 0) { - $options['timeout'] = $this->timeout_; - } - $this->buf_ = ''; - - $contextid = stream_context_create(array('http' => $options)); - $this->handle_ = @fopen($this->scheme_.'://'.$host.$this->uri_, 'r', false, $contextid); - - // Connect failed? - if ($this->handle_ === FALSE) { - $this->handle_ = null; - $error = 'THttpClient: Could not connect to '.$host.$this->uri_; - throw new TTransportException($error, TTransportException::NOT_OPEN); - } - } - -} - -?> diff --git a/include/Thrift/transport/TMemoryBuffer.php b/include/Thrift/transport/TMemoryBuffer.php deleted file mode 100644 index 01eb0f5a..00000000 --- a/include/Thrift/transport/TMemoryBuffer.php +++ /dev/null @@ -1,84 +0,0 @@ -buf_ = $buf; - } - - protected $buf_ = ''; - - public function isOpen() { - return true; - } - - public function open() {} - - public function close() {} - - public function write($buf) { - $this->buf_ .= $buf; - } - - public function read($len) { - if (strlen($this->buf_) === 0) { - throw new TTransportException('TMemoryBuffer: Could not read ' . - $len . ' bytes from buffer.', - TTransportException::UNKNOWN); - } - - if (strlen($this->buf_) <= $len) { - $ret = $this->buf_; - $this->buf_ = ''; - return $ret; - } - - $ret = substr($this->buf_, 0, $len); - $this->buf_ = substr($this->buf_, $len); - - return $ret; - } - - function getBuffer() { - return $this->buf_; - } - - public function available() { - return strlen($this->buf_); - } -} - -?> diff --git a/include/Thrift/transport/TNullTransport.php b/include/Thrift/transport/TNullTransport.php deleted file mode 100644 index bada5dfb..00000000 --- a/include/Thrift/transport/TNullTransport.php +++ /dev/null @@ -1,48 +0,0 @@ - diff --git a/include/Thrift/transport/TPhpStream.php b/include/Thrift/transport/TPhpStream.php deleted file mode 100644 index 3a1c80b8..00000000 --- a/include/Thrift/transport/TPhpStream.php +++ /dev/null @@ -1,111 +0,0 @@ -read_ = $mode & self::MODE_R; - $this->write_ = $mode & self::MODE_W; - } - - public function open() { - if ($this->read_) { - $this->inStream_ = @fopen(self::inStreamName(), 'r'); - if (!is_resource($this->inStream_)) { - throw new TException('TPhpStream: Could not open php://input'); - } - } - if ($this->write_) { - $this->outStream_ = @fopen('php://output', 'w'); - if (!is_resource($this->outStream_)) { - throw new TException('TPhpStream: Could not open php://output'); - } - } - } - - public function close() { - if ($this->read_) { - @fclose($this->inStream_); - $this->inStream_ = null; - } - if ($this->write_) { - @fclose($this->outStream_); - $this->outStream_ = null; - } - } - - public function isOpen() { - return - (!$this->read_ || is_resource($this->inStream_)) && - (!$this->write_ || is_resource($this->outStream_)); - } - - public function read($len) { - $data = @fread($this->inStream_, $len); - if ($data === FALSE || $data === '') { - throw new TException('TPhpStream: Could not read '.$len.' bytes'); - } - return $data; - } - - public function write($buf) { - while (strlen($buf) > 0) { - $got = @fwrite($this->outStream_, $buf); - if ($got === 0 || $got === FALSE) { - throw new TException('TPhpStream: Could not write '.strlen($buf).' bytes'); - } - $buf = substr($buf, $got); - } - } - - public function flush() { - @fflush($this->outStream_); - } - - private static function inStreamName() { - if (php_sapi_name() == 'cli') { - return 'php://stdin'; - } - return 'php://input'; - } - -} - -?> diff --git a/include/Thrift/transport/TSocket.php b/include/Thrift/transport/TSocket.php deleted file mode 100644 index ba3a6318..00000000 --- a/include/Thrift/transport/TSocket.php +++ /dev/null @@ -1,312 +0,0 @@ -host_ = $host; - $this->port_ = $port; - $this->persist_ = $persist; - $this->debugHandler_ = $debugHandler ? $debugHandler : 'error_log'; - } - - /** - * Sets the send timeout. - * - * @param int $timeout Timeout in milliseconds. - */ - public function setSendTimeout($timeout) { - $this->sendTimeout_ = $timeout; - } - - /** - * Sets the receive timeout. - * - * @param int $timeout Timeout in milliseconds. - */ - public function setRecvTimeout($timeout) { - $this->recvTimeout_ = $timeout; - } - - /** - * Sets debugging output on or off - * - * @param bool $debug - */ - public function setDebug($debug) { - $this->debug_ = $debug; - } - - /** - * Get the host that this socket is connected to - * - * @return string host - */ - public function getHost() { - return $this->host_; - } - - /** - * Get the remote port that this socket is connected to - * - * @return int port - */ - public function getPort() { - return $this->port_; - } - - /** - * Tests whether this is open - * - * @return bool true if the socket is open - */ - public function isOpen() { - return is_resource($this->handle_); - } - - /** - * Connects the socket. - */ - public function open() { - - if ($this->persist_) { - $this->handle_ = @pfsockopen($this->host_, - $this->port_, - $errno, - $errstr, - $this->sendTimeout_/1000.0); - } else { - $this->handle_ = @fsockopen($this->host_, - $this->port_, - $errno, - $errstr, - $this->sendTimeout_/1000.0); - } - - // Connect failed? - if ($this->handle_ === FALSE) { - $error = 'TSocket: Could not connect to '.$this->host_.':'.$this->port_.' ('.$errstr.' ['.$errno.'])'; - if ($this->debug_) { - call_user_func($this->debugHandler_, $error); - } - throw new TException($error); - } - - stream_set_timeout($this->handle_, 0, $this->sendTimeout_*1000); - $this->sendTimeoutSet_ = TRUE; - } - - /** - * Closes the socket. - */ - public function close() { - if (!$this->persist_) { - @fclose($this->handle_); - $this->handle_ = null; - } - } - - /** - * Uses stream get contents to do the reading - * - * @param int $len How many bytes - * @return string Binary data - */ - public function readAll($len) { - if ($this->sendTimeoutSet_) { - stream_set_timeout($this->handle_, 0, $this->recvTimeout_*1000); - $this->sendTimeoutSet_ = FALSE; - } - // This call does not obey stream_set_timeout values! - // $buf = @stream_get_contents($this->handle_, $len); - - $pre = null; - while (TRUE) { - $buf = @fread($this->handle_, $len); - if ($buf === FALSE || $buf === '') { - $md = stream_get_meta_data($this->handle_); - if ($md['timed_out']) { - throw new TException('TSocket: timed out reading '.$len.' bytes from '. - $this->host_.':'.$this->port_); - } else { - throw new TException('TSocket: Could not read '.$len.' bytes from '. - $this->host_.':'.$this->port_); - } - } else if (($sz = strlen($buf)) < $len) { - $md = stream_get_meta_data($this->handle_); - if ($md['timed_out']) { - throw new TException('TSocket: timed out reading '.$len.' bytes from '. - $this->host_.':'.$this->port_); - } else { - $pre .= $buf; - $len -= $sz; - } - } else { - return $pre.$buf; - } - } - } - - /** - * Read from the socket - * - * @param int $len How many bytes - * @return string Binary data - */ - public function read($len) { - if ($this->sendTimeoutSet_) { - stream_set_timeout($this->handle_, 0, $this->recvTimeout_*1000); - $this->sendTimeoutSet_ = FALSE; - } - $data = @fread($this->handle_, $len); - if ($data === FALSE || $data === '') { - $md = stream_get_meta_data($this->handle_); - if ($md['timed_out']) { - throw new TException('TSocket: timed out reading '.$len.' bytes from '. - $this->host_.':'.$this->port_); - } else { - throw new TException('TSocket: Could not read '.$len.' bytes from '. - $this->host_.':'.$this->port_); - } - } - return $data; - } - - /** - * Write to the socket. - * - * @param string $buf The data to write - */ - public function write($buf) { - if (!$this->sendTimeoutSet_) { - stream_set_timeout($this->handle_, 0, $this->sendTimeout_*1000); - $this->sendTimeoutSet_ = TRUE; - } - while (strlen($buf) > 0) { - $got = @fwrite($this->handle_, $buf); - if ($got === 0 || $got === FALSE) { - $md = stream_get_meta_data($this->handle_); - if ($md['timed_out']) { - throw new TException('TSocket: timed out writing '.strlen($buf).' bytes from '. - $this->host_.':'.$this->port_); - } else { - throw new TException('TSocket: Could not write '.strlen($buf).' bytes '. - $this->host_.':'.$this->port_); - } - } - $buf = substr($buf, $got); - } - } - - /** - * Flush output to the socket. - */ - public function flush() { - $ret = fflush($this->handle_); - if ($ret === FALSE) { - throw new TException('TSocket: Could not flush: '. - $this->host_.':'.$this->port_); - } - } -} - -?> diff --git a/include/Thrift/transport/TSocketPool.php b/include/Thrift/transport/TSocketPool.php deleted file mode 100644 index 7f1157cb..00000000 --- a/include/Thrift/transport/TSocketPool.php +++ /dev/null @@ -1,296 +0,0 @@ - $val) { - $ports[$key] = $port; - } - } - - foreach ($hosts as $key => $host) { - $this->servers_ []= array('host' => $host, - 'port' => $ports[$key]); - } - } - - /** - * Add a server to the pool - * - * This function does not prevent you from adding a duplicate server entry. - * - * @param string $host hostname or IP - * @param int $port port - */ - public function addServer($host, $port) { - $this->servers_[] = array('host' => $host, 'port' => $port); - } - - /** - * Sets how many time to keep retrying a host in the connect function. - * - * @param int $numRetries - */ - public function setNumRetries($numRetries) { - $this->numRetries_ = $numRetries; - } - - /** - * Sets how long to wait until retrying a host if it was marked down - * - * @param int $numRetries - */ - public function setRetryInterval($retryInterval) { - $this->retryInterval_ = $retryInterval; - } - - /** - * Sets how many time to keep retrying a host before marking it as down. - * - * @param int $numRetries - */ - public function setMaxConsecutiveFailures($maxConsecutiveFailures) { - $this->maxConsecutiveFailures_ = $maxConsecutiveFailures; - } - - /** - * Turns randomization in connect order on or off. - * - * @param bool $randomize - */ - public function setRandomize($randomize) { - $this->randomize_ = $randomize; - } - - /** - * Whether to always try the last server. - * - * @param bool $alwaysTryLast - */ - public function setAlwaysTryLast($alwaysTryLast) { - $this->alwaysTryLast_ = $alwaysTryLast; - } - - - /** - * Connects the socket by iterating through all the servers in the pool - * and trying to find one that works. - */ - public function open() { - // Check if we want order randomization - if ($this->randomize_) { - shuffle($this->servers_); - } - - // Count servers to identify the "last" one - $numServers = count($this->servers_); - - for ($i = 0; $i < $numServers; ++$i) { - - // This extracts the $host and $port variables - extract($this->servers_[$i]); - - // Check APC cache for a record of this server being down - $failtimeKey = 'thrift_failtime:'.$host.':'.$port.'~'; - - // Cache miss? Assume it's OK - $lastFailtime = apc_fetch($failtimeKey); - if ($lastFailtime === FALSE) { - $lastFailtime = 0; - } - - $retryIntervalPassed = FALSE; - - // Cache hit...make sure enough the retry interval has elapsed - if ($lastFailtime > 0) { - $elapsed = time() - $lastFailtime; - if ($elapsed > $this->retryInterval_) { - $retryIntervalPassed = TRUE; - if ($this->debug_) { - call_user_func($this->debugHandler_, - 'TSocketPool: retryInterval '. - '('.$this->retryInterval_.') '. - 'has passed for host '.$host.':'.$port); - } - } - } - - // Only connect if not in the middle of a fail interval, OR if this - // is the LAST server we are trying, just hammer away on it - $isLastServer = FALSE; - if ($this->alwaysTryLast_) { - $isLastServer = ($i == ($numServers - 1)); - } - - if (($lastFailtime === 0) || - ($isLastServer) || - ($lastFailtime > 0 && $retryIntervalPassed)) { - - // Set underlying TSocket params to this one - $this->host_ = $host; - $this->port_ = $port; - - // Try up to numRetries_ connections per server - for ($attempt = 0; $attempt < $this->numRetries_; $attempt++) { - try { - // Use the underlying TSocket open function - parent::open(); - - // Only clear the failure counts if required to do so - if ($lastFailtime > 0) { - apc_store($failtimeKey, 0); - } - - // Successful connection, return now - return; - - } catch (TException $tx) { - // Connection failed - } - } - - // Mark failure of this host in the cache - $consecfailsKey = 'thrift_consecfails:'.$host.':'.$port.'~'; - - // Ignore cache misses - $consecfails = apc_fetch($consecfailsKey); - if ($consecfails === FALSE) { - $consecfails = 0; - } - - // Increment by one - $consecfails++; - - // Log and cache this failure - if ($consecfails >= $this->maxConsecutiveFailures_) { - if ($this->debug_) { - call_user_func($this->debugHandler_, - 'TSocketPool: marking '.$host.':'.$port. - ' as down for '.$this->retryInterval_.' secs '. - 'after '.$consecfails.' failed attempts.'); - } - // Store the failure time - apc_store($failtimeKey, time()); - - // Clear the count of consecutive failures - apc_store($consecfailsKey, 0); - } else { - apc_store($consecfailsKey, $consecfails); - } - } - } - - // Holy shit we failed them all. The system is totally ill! - $error = 'TSocketPool: All hosts in pool are down. '; - $hosts = array(); - foreach ($this->servers_ as $server) { - $hosts []= $server['host'].':'.$server['port']; - } - $hostlist = implode(',', $hosts); - $error .= '('.$hostlist.')'; - if ($this->debug_) { - call_user_func($this->debugHandler_, $error); - } - throw new TException($error); - } -} - -?> diff --git a/include/Thrift/transport/TTransport.php b/include/Thrift/transport/TTransport.php deleted file mode 100644 index e2445259..00000000 --- a/include/Thrift/transport/TTransport.php +++ /dev/null @@ -1,108 +0,0 @@ -read($len); - - $data = ''; - $got = 0; - while (($got = strlen($data)) < $len) { - $data .= $this->read($len - $got); - } - return $data; - } - - /** - * Writes the given data out. - * - * @param string $buf The data to write - * @throws TTransportException if writing fails - */ - public abstract function write($buf); - - /** - * Flushes any pending data out of a buffer - * - * @throws TTransportException if a writing error occurs - */ - public function flush() {} -} - -?> diff --git a/include/Unicode.inc.php b/include/Unicode.inc.php deleted file mode 100644 index 936388c2..00000000 --- a/include/Unicode.inc.php +++ /dev/null @@ -1,250 +0,0 @@ - 0xBF) { - throw new Exception('convertUTF82Char: error2 ' . dechex($b) . '!'); - } - $counter--; - $outputString .= self::dec2char(($n << 6) | ($b-0x80)); - $n = 0; - break; - case 2: - case 3: - if ($b < 0x80 || $b > 0xBF) { - throw new Exception('convertUTF82Char: error3 ' . dechex($b) . '!'); - } - $n = ($n << 6) | ($b-0x80); - $counter--; - break; - } - } - return preg_replace('/ $/', '', $outputString); - } - - - public static function convertCharStr2CP($textString, $preserve, $pad, $type) { - // converts a string of characters to code points, separated by space - // textString: string, the string to convert - // preserve: string enum [ascii, latin1], a set of characters to not convert - // pad: boolean, if true, hex numbers lower than 1000 are padded with zeros - // type: string enum[hex, dec, unicode, zerox], whether output should be in hex or dec or unicode U+ form - $haut = 0; - $n = 0; - $CPString = ''; - $afterEscape = false; - for ($i = 0; $i < mb_strlen($textString); $i++) { - $b = Z_Unicode::charCodeAt($textString, $i); - if ($b < 0 || $b > 0xFFFF) { - throw new Exception('Error in convertChar2CP: byte out of range ' . dechex($b) . '!'); - } - if ($haut != 0) { - if (0xDC00 <= $b && $b <= 0xDFFF) { //alert('12345'.slice(-1).match(/[A-Fa-f0-9]/)+'<'); - //if ($CPString.slice(-1).match(/[A-Za-z0-9]/) != null) { $CPString += ' '; } - if ($afterEscape) { $CPString .= ' '; } - if (type == 'hex') { - $CPString .= dechex(0x10000 . (($haut - 0xD800) << 10) . ($b - 0xDC00)); - } - else if (type == 'unicode') { - $CPString .= 'U+'+dechex(0x10000 . (($haut - 0xD800) << 10) . ($b - 0xDC00)); - } - else if (type == 'zerox') { - $CPString .= '0x'+dechex(0x10000 . (($haut - 0xD800) << 10) . ($b - 0xDC00)); - } - else { - $CPString .= 0x10000 . (($haut - 0xD800) << 10) . ($b - 0xDC00); - } - $haut = 0; - continue; - } - else { - throw new Exception('Error in convertChar2CP: surrogate out of range ' . dechex($haut) . '!'); - } - } - if (0xD800 <= $b && $b <= 0xDBFF) { - $haut = $b; - } - else { - if ($b <= 127 && $preserve == 'ascii') { - $CPString .= Z_Unicode::charAt($textString, $i); - $afterEscape = false; - } - else if ($b <= 255 && $preserve == 'latin1') { - $CPString .= Z_Unicode::charAt($textString, $i); - $afterEscape = false; - } - else { - //if ($CPString.slice(-1).match(/[A-Za-z0-9]/) != null) { $CPString += ' '; } - if ($afterEscape) { $CPString .= ' '; } - if ($type == 'hex') { - $cp = dechex($b); - if ($pad) { while (strlen($cp) < 4) { $cp = '0' . $cp; } } - } - else if ($type == 'unicode') { - $cp = dechex($b); - if ($pad) { while (strlen($length) < 4) { $cp = '0' . $cp; } } - $CPString .= 'U+'; - } - else if ($type == 'zerox') { - $cp = dechex($b); - if ($pad) { while (strlen($cp) < 4) { $cp = '0' . $cp; } } - $CPString .= '0x'; - } - else { - $cp = $b; - } - $CPString .= $cp; - $afterEscape = true; - } - } - } - return strtoupper($CPString); - } - - - public static function convertCharStr2UTF8($str) { - // Converts a string of characters to UTF-8 byte codes, separated by spaces - // str: sequence of Unicode characters - $highsurrogate = 0; - $suppCP; // decimal code point value for a supp char - $n = 0; - $outputString = ''; - for ($i = 0; $i < mb_strlen($str); $i++) { - $cc = self::charCodeAt($str, $i); - if ($cc < 0 || $cc > 0xFFFF) { - throw new Exception('Error in convertCharStr2UTF8: unexpected charCodeAt result, cc=' . $cc . '!'); - } - if ($highsurrogate != 0) { - if (0xDC00 <= $cc && $cc <= 0xDFFF) { - $suppCP = 0x10000 + (($highsurrogate - 0xD800) << 10) + ($cc - 0xDC00); - $outputString .= ' ' . self::dec2hex2(0xF0 | (($suppCP>>18) & 0x07)) . ' ' - . self::dec2hex2(0x80 | (($suppCP>>12) & 0x3F)) . ' ' - . self::dec2hex2(0x80 | (($suppCP>>6) & 0x3F)) . ' ' - . self::dec2hex2(0x80 | ($suppCP & 0x3F)); - $highsurrogate = 0; - continue; - } - else { - throw new Exception('Error in convertCharStr2UTF8: low surrogate expected, cc=' . $cc . '!'); - $highsurrogate = 0; - } - } - if (0xD800 <= $cc && $cc <= 0xDBFF) { // high surrogate - $highsurrogate = $cc; - } - else { - if ($cc <= 0x7F) { $outputString .= ' ' . self::dec2hex2($cc); } - else if ($cc <= 0x7FF) { $outputString .= ' ' . self::dec2hex2(0xC0 | (($cc>>6) & 0x1F)) . ' ' . self::dec2hex2(0x80 | ($cc & 0x3F)); } - else if ($cc <= 0xFFFF) { $outputString .= ' ' . self::dec2hex2(0xE0 | (($cc>>12) & 0x0F)) . ' ' . self::dec2hex2(0x80 | (($cc>>6) & 0x3F)) . ' ' . self::dec2hex2(0x80 | ($cc & 0x3F)); } - } - } - return substr($outputString, 1); - } - - - private static function dec2char($n) { - // converts a single string representing a decimal number to a character - // note that no checking is performed to ensure that this is just a hex number, eg. no spaces etc - // dec: string, the dec codepoint to be converted - $result = ''; - if ($n <= 0xFFFF) { - $result .= Z_Unicode::fromCharCode($n); - } - else if ($n <= 0x10FFFF) { - $n -= 0x10000; - $result .= Z_Unicode::fromCharCode(0xD800 | ($n >> 10)) . Z_Unicode::fromCharCode(0xDC00 | ($n & 0x3FF)); - } - else { - throw new Exception('dec2char error: Code point out of range: ' . dechex($n)); - } - return $result; - } - - - public static function hex2char ($hex) { - // converts a single hex number to a character - // note that no checking is performed to ensure that this is just a hex number, eg. no spaces etc - // hex: string, the hex codepoint to be converted - $result = ''; - $n = intval($hex, 16); - if ($n <= 0xFFFF) { $result .= self::fromCharCode($n); } - else if ($n <= 0x10FFFF) { - $n -= 0x10000; - $result .= self::fromCharCode(0xD800 | ($n >> 10)) . self::fromCharCode(0xDC00 | ($n & 0x3FF)); - } - else { throw new Exception('hex2char error: Code point out of range: ' . dexhex($n)); } - return $result; - } - - - public static function dec2hex2($textString) { - $hexequiv = array("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"); - return $hexequiv[($textString >> 4) & 0xF] . $hexequiv[$textString & 0xF]; - } -} diff --git a/include/config/.gitignore b/include/config/.gitignore deleted file mode 100644 index 75d5bb72..00000000 --- a/include/config/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -config.inc.php -dbconnect.inc.php diff --git a/include/config/config.inc.php-sample b/include/config/config.inc.php-sample deleted file mode 100644 index d7db38f7..00000000 --- a/include/config/config.inc.php-sample +++ /dev/null @@ -1,105 +0,0 @@ - [ - 'host' => 'redis1.localdomain:6379' - ], - 'request-limiter' => [ - 'host' => 'redis-transient.localdomain:6379' - ], - 'notifications' => [ - 'host' => 'redis-transient.localdomain:6379' - ], - 'fulltext-migration' => [ - 'host' => 'redis-transient.localdomain:6379', - 'cluster' => true - ] - ]; - - public static $REDIS_PREFIX = ''; - - public static $MEMCACHED_ENABLED = true; - public static $MEMCACHED_SERVERS = array( - 'memcached1.localdomain:11211:2', 'memcached2.localdomain:11211:1' - ); - - public static $TRANSLATION_SERVERS = array( - "translation1.localdomain:1969" - ); - - public static $CITATION_SERVERS = array( - "citeserver1.localdomain:8080", "citeserver2.localdomain:8080" - ); - - public static $SEARCH_HOSTS = ['']; - - public static $GLOBAL_ITEMS_URL = ''; - - public static $ATTACHMENT_SERVER_HOSTS = array("files1.localdomain", "files2.localdomain"); - public static $ATTACHMENT_SERVER_DYNAMIC_PORT = 80; - public static $ATTACHMENT_SERVER_STATIC_PORT = 81; - public static $ATTACHMENT_SERVER_URL = "https://files.example.net"; - public static $ATTACHMENT_SERVER_DOCROOT = "/var/www/attachments/"; - - public static $STATSD_ENABLED = false; - public static $STATSD_PREFIX = ""; - public static $STATSD_HOST = "monitor.localdomain"; - public static $STATSD_PORT = 8125; - - public static $LOG_TO_SCRIBE = false; - public static $LOG_ADDRESS = ''; - public static $LOG_PORT = 1463; - public static $LOG_TIMEZONE = 'US/Eastern'; - public static $LOG_TARGET_DEFAULT = 'errors'; - - public static $HTMLCLEAN_SERVER_URL = ''; - - public static $PROCESSOR_PORT_DOWNLOAD = 3455; - public static $PROCESSOR_PORT_UPLOAD = 3456; - public static $PROCESSOR_PORT_ERROR = 3457; - - public static $PROCESSOR_LOG_TARGET_DOWNLOAD = 'sync-processor-download'; - public static $PROCESSOR_LOG_TARGET_UPLOAD = 'sync-processor-upload'; - public static $PROCESSOR_LOG_TARGET_ERROR = 'sync-processor-error'; - - public static $SYNC_DOWNLOAD_SMALLEST_FIRST = false; - public static $SYNC_UPLOAD_SMALLEST_FIRST = false; - - // Set some things manually for running via command line - public static $CLI_PHP_PATH = '/usr/bin/php'; - - public static $SYNC_ERROR_PATH = '/var/log/httpd/sync-errors/'; - public static $ERROR_PATH = '/var/log/httpd/errors/'; - - public static $CACHE_VERSION_ATOM_ENTRY = 1; - public static $CACHE_VERSION_BIB = 1; - public static $CACHE_VERSION_ITEM_DATA = 1; -} -?> diff --git a/include/config/dbconnect.inc.php-sample b/include/config/dbconnect.inc.php-sample deleted file mode 100644 index f2aefcef..00000000 --- a/include/config/dbconnect.inc.php-sample +++ /dev/null @@ -1,67 +0,0 @@ - '' - ] - ]; - $db = ''; - $user = ''; - $pass = ''; - $state = 'up'; // 'up', 'readonly', 'down' - } - else if ($db == 'shard') { - $host = false; - $port = false; - $db = false; - $user = ''; - $pass = ''; - } - else if ($db == 'id1') { - $host = ''; - $port = 3306; - $db = 'ids'; - $user = ''; - $pass = ''; - } - else if ($db == 'id2') { - $host = ''; - $port = 3306; - $db = 'ids'; - $user = ''; - $pass = ''; - } - else if ($db == 'www1') { - $host = ''; - $port = 3306; - $db = 'www'; - $user = ''; - $pass = ''; - } - else if ($db == 'www2') { - $host = ''; - $port = 3306; - $db = 'www'; - $user = ''; - $pass = ''; - } - else { - throw new Exception("Invalid db '$db'"); - } - return [ - 'host' => $host, - 'replicas' => !empty($replicas) ? $replicas : [], - 'port' => $port, - 'db' => $db, - 'user' => $user, - 'pass' => $pass, - 'charset' => $charset, - 'state' => !empty($state) ? $state : 'up' - ]; -} -?> diff --git a/include/config/routes.inc.php b/include/config/routes.inc.php deleted file mode 100644 index c463fac4..00000000 --- a/include/config/routes.inc.php +++ /dev/null @@ -1,113 +0,0 @@ -map('/', array('controller' => 'Sync', 'action' => 'index')); - $router->map('/:action', array('controller' => 'Sync')); -} -// API -else { - $router->map('/', array('controller' => 'Api', 'action' => 'noop', 'extra' => array('allowHTTP' => true))); - - // Global items - $router->map('/globalitems', ['controller' => 'GlobalItems', 'extra' => ['globalItems' => true]]); - $router->map('/globalitems/:objectGlobalItemID/items', ['controller' => 'Items', 'extra' => ['globalItems' => true]]); - - // Groups - $router->map('/groups/i:objectGroupID', array('controller' => 'Groups')); - $router->map('/groups/i:scopeObjectID/users/i:objectID', array('controller' => 'Groups', 'action' => 'groupUsers')); - - // Top-level objects - $router->map('/users/i:objectUserID/publications/items/top', ['controller' => 'Items', 'extra' => ['subset' => 'top', 'publications' => true]]); - $router->map('/users/i:objectUserID/:controller/top', array('extra' => array('subset' => 'top'))); - $router->map('/groups/i:objectGroupID/:controller/top', array('extra' => array('subset' => 'top'))); - - // Attachment files - $router->map('/users/i:objectUserID/laststoragesync', array('controller' => 'Storage', 'action' => 'laststoragesync', 'extra' => array('auth' => true))); - $router->map('/groups/i:objectGroupID/laststoragesync', array('controller' => 'Storage', 'action' => 'laststoragesync', 'extra' => array('auth' => true))); - $router->map('/users/i:objectUserID/storageadmin', array('controller' => 'Storage', 'action' => 'storageadmin')); - $router->map('/storagepurge', array('controller' => 'Storage', 'action' => 'storagepurge')); - $router->map('/users/i:objectUserID/removestoragefiles', array('controller' => 'Storage', 'action' => 'removestoragefiles', 'extra' => array('allowHTTP' => true))); - $router->map('/users/i:objectUserID/items/:objectKey/file', array('controller' => 'Items', 'extra' => array('allowHTTP' => true, 'file' => true))); - $router->map('/users/i:objectUserID/items/:objectKey/file/view', array('controller' => 'Items', 'extra' => array('allowHTTP' => true, 'file' => true, 'view' => true))); - $router->map('/users/i:objectUserID/publications/items/:objectKey/file', ['controller' => 'Items', 'extra' => ['allowHTTP' => true, 'file' => true, 'publications' => true]]); - $router->map('/users/i:objectUserID/publications/items/:objectKey/file/view', ['controller' => 'Items', 'extra' => ['allowHTTP' => true, 'file' => true, 'view' => true, 'publications' => true]]); - $router->map('/groups/i:objectGroupID/items/:objectKey/file', array('controller' => 'Items', 'extra' => array('allowHTTP' => true, 'file' => true))); - $router->map('/groups/i:objectGroupID/items/:objectKey/file/view', array('controller' => 'Items', 'extra' => array('allowHTTP' => true, 'file' => true, 'view' => true))); - - // Full-text content - $router->map('/users/i:objectUserID/items/:objectKey/fulltext', array('controller' => 'FullText', 'action' => 'itemContent')); - //$router->map('/users/i:objectUserID/publications/items/:objectKey/fulltext', ['controller' => 'FullText', 'action' => 'itemContent', 'extra' => ['publications' => true]]); - $router->map('/groups/i:objectGroupID/items/:objectKey/fulltext', array('controller' => 'FullText', 'action' => 'itemContent')); - $router->map('/users/i:objectUserID/fulltext', array('controller' => 'FullText', 'action' => 'fulltext')); - //$router->map('/users/i:objectUserID/publications/fulltext', ['controller' => 'FullText', 'action' => 'fulltext', 'extra' => ['publications' => true]]); - $router->map('/groups/i:objectGroupID/fulltext', array('controller' => 'FullText', 'action' => 'fulltext')); - - // All trashed items - $router->map('/users/i:objectUserID/items/trash', array('controller' => 'Items', 'extra' => array('subset' => 'trash'))); - $router->map('/groups/i:objectGroupID/items/trash', array('controller' => 'Items', 'extra' => array('subset' => 'trash'))); - - // Subcollections, single and multiple - $router->map('/users/i:objectUserID/collections/:scopeObjectKey/collections/:objectKey', array('controller' => 'Collections', 'extra' => array('scopeObject' => 'collections'))); - $router->map('/groups/i:objectGroupID/collections/:scopeObjectKey/collections/:objectKey', array('controller' => 'Collections','extra' => array('scopeObject' => 'collections'))); - - // Deleted items in a collection - $router->map('/users/i:objectUserID/:scopeObject/:scopeObjectKey/items/trash', array('controller' => 'Items', 'extra' => array('subset' => 'trash'))); - - // Tags, which have names instead of ids - $router->map('/users/i:objectUserID/tags/:scopeObjectName/items/:objectName/:subset', array('controller' => 'Items', 'extra' => array('scopeObject' => 'tags'))); - $router->map('/groups/i:objectGroupID/tags/:scopeObjectName/items/:objectName/:subset', array('controller' => 'Items', 'extra' => array('scopeObject' => 'tags'))); - $router->map('/users/i:objectUserID/tags/:objectName/:subset', array('controller' => 'Tags')); - //$router->map('/users/i:objectUserID/publications/tags/:objectName/:subset', ['controller' => 'Tags', 'extra' => ['publications' => true]]); - $router->map('/groups/i:objectGroupID/tags/:objectName/:subset', array('controller' => 'Tags')); - - // Tags within something else - //$router->map('/users/i:objectUserID/publications/items/:scopeObjectKey/tags/:objectKey/:subset', ['controller' => 'Tags', 'extra' => ['publications']]); - $router->map('/users/i:objectUserID/:scopeObject/:scopeObjectKey/tags/:objectKey/:subset', array('controller' => 'Tags')); - $router->map('/groups/i:objectGroupID/:scopeObject/:scopeObjectKey/tags/:objectKey/:subset', array('controller' => 'Tags')); - - // Top-level items within something else - $router->map('/users/i:objectUserID/:scopeObject/:scopeObjectKey/items/top', array('controller' => 'Items', 'extra' => array('subset' => 'top'))); - $router->map('/groups/i:objectGroupID/:scopeObject/:scopeObjectKey/items/top', array('controller' => 'Items', 'extra' => array('subset' => 'top'))); - - // Items within something else - $router->map('/users/i:objectUserID/:scopeObject/:scopeObjectKey/items/:objectKey/:subset', array('controller' => 'Items')); - $router->map('/groups/i:objectGroupID/:scopeObject/:scopeObjectKey/items/:objectKey/:subset', array('controller' => 'Items')); - - // User API keys - $router->map('/keys/:objectName', array('controller' => 'Keys')); - $router->map('/users/i:objectUserID/keys/:objectName', array('controller' => 'Keys')); - - // User/library settings - $router->map('/users/i:objectUserID/settings/:objectKey', array('controller' => 'settings')); - $router->map('/groups/i:objectGroupID/settings/:objectKey', array('controller' => 'settings')); - - // Clear (for testing) - $router->map('/users/i:objectUserID/clear', array('controller' => 'Api', 'action' => 'clear')); - $router->map('/groups/i:objectGroupID/clear', array('controller' => 'Api', 'action' => 'clear')); - - // My Publications items - $router->map('/users/i:objectUserID/publications/settings', ['controller' => 'settings', 'extra' => ['publications' => true]]); // TEMP - $router->map('/users/i:objectUserID/publications/deleted', ['controller' => 'deleted', 'extra' => ['publications' => true]]); // TEMP - $router->map('/users/i:objectUserID/publications/items/:objectKey/children', ['controller' => 'Items', 'extra' => ['publications' => true, 'subset' => 'children']]); - $router->map('/users/i:objectUserID/publications/items/:objectKey', ['controller' => 'Items', 'extra' => ['publications' => true]]); - - // Other top-level URLs, with an optional key and subset - $router->map('/users/i:objectUserID/:controller/:objectKey/:subset'); - $router->map('/groups/i:objectGroupID/:controller/:objectKey/:subset'); - - $router->map('/itemTypes', array('controller' => 'Mappings', 'extra' => array('subset' => 'itemTypes'))); - $router->map('/itemTypeFields', array('controller' => 'Mappings', 'extra' => array('subset' => 'itemTypeFields'))); - $router->map('/itemFields', array('controller' => 'Mappings', 'extra' => array('subset' => 'itemFields'))); - $router->map('/itemTypeCreatorTypes', array('controller' => 'Mappings', 'extra' => array('subset' => 'itemTypeCreatorTypes'))); - $router->map('/creatorFields', array('controller' => 'Mappings', 'extra' => array('subset' => 'creatorFields'))); - $router->map('/items/new', array('controller' => 'Mappings', 'action' => 'newItem')); - - $router->map('/test/setup', array('controller' => 'Api', 'action' => 'testSetup')); -} - -return $router->match($_SERVER['REQUEST_URI']); diff --git a/include/footer.inc.php b/include/footer.inc.php deleted file mode 100644 index 74373efc..00000000 --- a/include/footer.inc.php +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/include/functions/array.inc.php b/include/functions/array.inc.php deleted file mode 100644 index 7ac9510d..00000000 --- a/include/functions/array.inc.php +++ /dev/null @@ -1,52 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Z_Array { - public static function array2string($array, $html=false) { - ob_start(); - $html ? var_dump($array) : print_r($array); - return ob_get_clean(); - } - - /** - * Removes empty keys from an array (preserving indexes) - * - * @param array $array - * @return array - **/ - public static function array_remove_empty($array){ - return array_filter($array, create_function('$val', 'return $val===0 || $val==="0" || !empty($val);')); - } - - - public static function filterKeys($array, $keysToKeep) { - return array_intersect_key( - $array, - array_flip($keysToKeep) - ); - } -} -?> diff --git a/include/functions/string.inc.php b/include/functions/string.inc.php deleted file mode 100644 index 9c19794c..00000000 --- a/include/functions/string.inc.php +++ /dev/null @@ -1,48 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Z_String { - /** - * Convert a CamelCase string into underscore_case - * - * @param string $string - * @return string - **/ - public static function camel2under($string){ - return strtolower(preg_replace(array('/([a-z])([A-Z])/'), array('$1_$2'), $string)); - } - - /** - * Convert an underscore_case string to CamelCase string - * - * @param string $string - * @return string - **/ - public static function under2camel($string){ - return str_replace(' ', '', ucwords(str_replace('_', ' ', $string))); - } -} -?> diff --git a/include/interfaces/IAuthenticationPlugin.inc.php b/include/interfaces/IAuthenticationPlugin.inc.php deleted file mode 100644 index 09bad5d0..00000000 --- a/include/interfaces/IAuthenticationPlugin.inc.php +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/include/log.inc.php b/include/log.inc.php deleted file mode 100644 index a2af4b03..00000000 --- a/include/log.inc.php +++ /dev/null @@ -1,144 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -// Thrift/Scribe -$THRIFT_ROOT = Z_ENV_BASE_PATH . 'include/Thrift'; - -class Z_Log { - /** - * Log a single message - */ - public static function log($category, $message) { - self::logm( - array( - array($category, $message) - ) - ); - } - - /** - * Log an array of category/message pairs - */ - public static function logm($categoryMessagePairs) { - $scribe = Z_CONFIG::$LOG_TO_SCRIBE; - $cli = Z_Core::isCommandLine(); - - // Scribe and CLI need additional info - if ($scribe || $cli) { - // Parse timestamp into date and milliseconds - $ts = microtime(true); - if (strpos($ts, '.') === false) { - $ts .= '.'; - } - list($ts, $msec) = explode('.', $ts); - $date = new DateTime(date(DATE_RFC822, $ts)); - $date->setTimezone(new DateTimeZone(Z_CONFIG::$LOG_TIMEZONE)); - $date = $date->format('Y-m-d H:i:s') . '.' . str_pad($msec, 4, '0'); - - // Get remote IP address - if (!$cli) { - $ipAddress = IPAddress::getIP(); - } - - // Get server hostname - if ($scribe) { - $host = gethostname(); - if (strpos($host, '.') !== false) { - $host = substr($host, 0, strpos($host, '.')); - } - } - } - - $messages = array(); - foreach ($categoryMessagePairs as $pair) { - // Scribe - if ($scribe) { - $messages[] = array( - 'category' => $pair[0], - 'message' => "$date [$ipAddress] [$host] " . $pair[1] - ); - } - // CLI - else if ($cli) { - $messages[] = array( - 'category' => $pair[0], - 'message' => $date . " " . $pair[1] - ); - } - // Web - else { - $messages[] = array( - 'category' => $pair[0], - 'message' => $pair[1] - ); - } - } - - if (Z_CONFIG::$LOG_TO_SCRIBE) { - self::logToScribe($messages); - } - else { - self::logToErrorLog($messages); - } - } - - private static function logToErrorLog($messages) { - foreach ($messages as $message) { - error_log($message['message']); - } - } - - private static function logToStdOut($messages) { - foreach ($messages as $message) { - echo $message['message'] . "\n"; - } - } - - private static function logToScribe($messages) { - GLOBAL $THRIFT_ROOT; - require_once($THRIFT_ROOT . '/Thrift.php'); - require_once($THRIFT_ROOT . '/transport/TSocket.php'); - require_once($THRIFT_ROOT . '/transport/TFramedTransport.php'); - require_once($THRIFT_ROOT . '/protocol/TBinaryProtocol.php'); - require_once('Scribe.php'); - - $entries = array(); - foreach ($messages as $message) { - $entries[] = new LogEntry($message); - } - - $socket = new TSocket(Z_CONFIG::$LOG_ADDRESS, Z_CONFIG::$LOG_PORT, true); - $transport = new TFramedTransport($socket); - $protocol = new TBinaryProtocol($transport, false, false); - $scribe = new scribeClient($protocol, $protocol); - - $transport->open(); - $scribe->Log($entries); - $transport->close(); - } -} - -?> \ No newline at end of file diff --git a/include/mvc/Controller.inc.php b/include/mvc/Controller.inc.php deleted file mode 100644 index 9e8bbfdb..00000000 --- a/include/mvc/Controller.inc.php +++ /dev/null @@ -1,23 +0,0 @@ -name = $controllerName; - $this->action = $action; - - foreach ($params as $key => $val) { - switch ($key) { - case 'controller': - case 'action': - case 'extra': - break; - - default: - $this->$key = $val; - } - } - } -} diff --git a/include/mvc/Router.inc.php b/include/mvc/Router.inc.php deleted file mode 100644 index fc309ce7..00000000 --- a/include/mvc/Router.inc.php +++ /dev/null @@ -1,94 +0,0 @@ -routes[$path] = array( - "parts" => explode("/", trim($path, "/")), - "params" => $params - ); - } - - - public function match($url) { - $path = parse_url($url, PHP_URL_PATH); - $pathParts = explode("/", str_replace("//", "/", trim($path, "/"))); - - if ($path == '/') { - return $this->routes['/']['params']; - } - unset($this->routes['/']); - - //var_dump("path is $path"); - //var_dump($pathParts); - - //var_dump("\n\n\n"); - - foreach ($this->routes as $path => $route) { - //ar_dump('=========='); - //ar_dump("path: " . $path); - //ar_dump("route:"); - //ar_dump($route); - - for ($i = 0, $len = sizeOf($route['parts']); $i < $len; $i++) { - $routePart = $route['parts'][$i]; - - //var_dump("Route part $routePart"); - //if (isset($pathParts[$i])) { - // var_dump("Path part $pathParts[$i]"); - //} - //else { - // var_dump("No path part"); - //} - - // If route part is a placeholder, sub in the path part for the - // given variable, or false if not present - if (strpos($routePart, ":") !== false) { - $p = explode(":", $routePart); - - $part = isset($pathParts[$i]) ? $pathParts[$i] : false; - - if ($p[0]) { - switch ($p[0]) { - case 'i': - if (!is_numeric($part)) { - continue 2; - } - $part = (int) $part; - break; - - default: - throw new Exception("Invalid route type '{$p[0]}'"); - } - } - else { - $part = urldecode($part); - } - - $route['params'][$p[1]] = $part; - } - // The path doesn't match this component - else if (!isset($pathParts[$i]) || $pathParts[$i] != $routePart) { - continue 2; - } - } - - // Route doesn't cover the whole path - if (isset($pathParts[$i])) { - continue; - } - - if (Z_ENV_TESTING_SITE && !empty($_GET['showroute'])) { - var_dump($path); - var_dump($route['params']); - exit; - } - return $route['params']; - } - - return false; - } -} \ No newline at end of file diff --git a/ls.Dockerfile b/ls.Dockerfile new file mode 100644 index 00000000..892c50a3 --- /dev/null +++ b/ls.Dockerfile @@ -0,0 +1,6 @@ + +############################ +# localstack image +############################ + +FROM atlassianlabs/localstack \ No newline at end of file diff --git a/ls.Dockerfile.dockerignore b/ls.Dockerfile.dockerignore new file mode 100644 index 00000000..44d4ecf7 --- /dev/null +++ b/ls.Dockerfile.dockerignore @@ -0,0 +1,20 @@ +**/secret.json +**/secret.txt +**/secret.yaml +.git +.github +.env +.vscode/ +bin +build +client +dataserver +doc +docker +docker-compose.yml +logs +stream-server +tinymce-clean-server +Zend +zotprime-k8s + diff --git a/m.Dockerfile b/m.Dockerfile new file mode 100644 index 00000000..2ad06618 --- /dev/null +++ b/m.Dockerfile @@ -0,0 +1,6 @@ + +############################ +# memcached image +############################ + +FROM memcached:1.5 \ No newline at end of file diff --git a/m.Dockerfile.dockerignore b/m.Dockerfile.dockerignore new file mode 100644 index 00000000..44d4ecf7 --- /dev/null +++ b/m.Dockerfile.dockerignore @@ -0,0 +1,20 @@ +**/secret.json +**/secret.txt +**/secret.yaml +.git +.github +.env +.vscode/ +bin +build +client +dataserver +doc +docker +docker-compose.yml +logs +stream-server +tinymce-clean-server +Zend +zotprime-k8s + diff --git a/minio.Dockerfile b/minio.Dockerfile new file mode 100644 index 00000000..e4723d73 --- /dev/null +++ b/minio.Dockerfile @@ -0,0 +1,10 @@ + +############################ +# minio image +############################ + +# image: minio/minio:RELEASE.2018-10-25T01-27-03Z +# image: minio/minio:RELEASE.2019-01-31T00-31-19Z +# image: minio/minio:RELEASE.2021-06-14T01-29-23Z +# image: minio/minio:RELEASE.2021-10-02T16-31-05Z +FROM minio/minio \ No newline at end of file diff --git a/minio.Dockerfile.dockerignore b/minio.Dockerfile.dockerignore new file mode 100644 index 00000000..44d4ecf7 --- /dev/null +++ b/minio.Dockerfile.dockerignore @@ -0,0 +1,20 @@ +**/secret.json +**/secret.txt +**/secret.yaml +.git +.github +.env +.vscode/ +bin +build +client +dataserver +doc +docker +docker-compose.yml +logs +stream-server +tinymce-clean-server +Zend +zotprime-k8s + diff --git a/miniomc.Dockerfile b/miniomc.Dockerfile new file mode 100644 index 00000000..9e329d25 --- /dev/null +++ b/miniomc.Dockerfile @@ -0,0 +1,10 @@ + +############################ +# mc image +############################ + +FROM minio/mc + +COPY docker/miniomc/entrypoint.sh / +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/miniomc.Dockerfile.dockerignore b/miniomc.Dockerfile.dockerignore new file mode 100644 index 00000000..2a791a46 --- /dev/null +++ b/miniomc.Dockerfile.dockerignore @@ -0,0 +1,22 @@ +**/secret.json +**/secret.txt +**/secret.yaml +.git +.github +.env +.vscode/ +bin +build +client +dataserver +doc +docker/dataserver +docker/db +docker/stream-server +docker-compose.yml +logs +stream-server +tinymce-clean-server +Zend +zotprime-k8s + diff --git a/misc/coredata.sql b/misc/coredata.sql deleted file mode 100644 index 4fa07384..00000000 --- a/misc/coredata.sql +++ /dev/null @@ -1,1169 +0,0 @@ --- ***** BEGIN LICENSE BLOCK ***** --- --- This file is part of the Zotero Data Server. --- --- Copyright © 2010 Center for History and New Media --- George Mason University, Fairfax, Virginia, USA --- http://zotero.org --- --- This program is free software: you can redistribute it and/or modify --- it under the terms of the GNU Affero General Public License as published by --- the Free Software Foundation, either version 3 of the License, or --- (at your option) any later version. --- --- This program is distributed in the hope that it will be useful, --- but WITHOUT ANY WARRANTY; without even the implied warranty of --- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the --- GNU Affero General Public License for more details. --- --- You should have received a copy of the GNU Affero General Public License --- along with this program. If not, see . --- --- ***** END LICENSE BLOCK ***** - -BEGIN; - -DELETE FROM charsets; -DELETE FROM itemTypes; -DELETE FROM fields; -DELETE FROM creatorTypes; - -INSERT INTO `itemTypes` (`itemTypeID`, `itemTypeName`, `custom`) VALUES -(1, 'note', 0), -(2, 'book', 0), -(3, 'bookSection', 0), -(4, 'journalArticle', 0), -(5, 'magazineArticle', 0), -(6, 'newspaperArticle', 0), -(7, 'thesis', 0), -(8, 'letter', 0), -(9, 'manuscript', 0), -(10, 'interview', 0), -(11, 'film', 0), -(12, 'artwork', 0), -(13, 'webpage', 0), -(14, 'attachment', 0), -(15, 'report', 0), -(16, 'bill', 0), -(17, 'case', 0), -(18, 'hearing', 0), -(19, 'patent', 0), -(20, 'statute', 0), -(21, 'email', 0), -(22, 'map', 0), -(23, 'blogPost', 0), -(24, 'instantMessage', 0), -(25, 'forumPost', 0), -(26, 'audioRecording', 0), -(27, 'presentation', 0), -(28, 'videoRecording', 0), -(29, 'tvBroadcast', 0), -(30, 'radioBroadcast', 0), -(31, 'podcast', 0), -(32, 'computerProgram', 0), -(33, 'conferencePaper', 0), -(34, 'document', 0), -(35, 'encyclopediaArticle', 0), -(36, 'dictionaryEntry', 0), -(10001, 'nsfReviewer', 1); - -INSERT INTO `creatorTypes` (`creatorTypeID`, `creatorTypeName`, `custom`) VALUES -(1, 'author', 0), -(2, 'contributor', 0), -(3, 'editor', 0), -(4, 'translator', 0), -(5, 'seriesEditor', 0), -(6, 'interviewee', 0), -(7, 'interviewer', 0), -(8, 'director', 0), -(9, 'scriptwriter', 0), -(10, 'producer', 0), -(11, 'castMember', 0), -(12, 'sponsor', 0), -(13, 'counsel', 0), -(14, 'inventor', 0), -(15, 'attorneyAgent', 0), -(16, 'recipient', 0), -(17, 'performer', 0), -(18, 'composer', 0), -(19, 'wordsBy', 0), -(20, 'cartographer', 0), -(21, 'programmer', 0), -(22, 'artist', 0), -(23, 'commenter', 0), -(24, 'presenter', 0), -(25, 'guest', 0), -(26, 'podcaster', 0), -(27, 'reviewedAuthor', 0), -(28, 'cosponsor', 0), -(29, 'bookAuthor', 0); - - -INSERT INTO `fields` (`fieldID`, `fieldName`, `fieldFormatID`, `custom`) VALUES -(1, 'url', NULL, 0), -(2, 'rights', NULL, 0), -(3, 'series', NULL, 0), -(4, 'volume', NULL, 0), -(5, 'issue', NULL, 0), -(6, 'edition', NULL, 0), -(7, 'place', NULL, 0), -(8, 'publisher', NULL, 0), -(10, 'pages', NULL, 0), -(11, 'ISBN', NULL, 0), -(12, 'publicationTitle', NULL, 0), -(13, 'ISSN', NULL, 0), -(14, 'date', NULL, 0), -(15, 'section', NULL, 0), -(18, 'callNumber', NULL, 0), -(19, 'archiveLocation', NULL, 0), -(21, 'distributor', NULL, 0), -(22, 'extra', NULL, 0), -(25, 'journalAbbreviation', NULL, 0), -(26, 'DOI', NULL, 0), -(27, 'accessDate', NULL, 0), -(28, 'seriesTitle', NULL, 0), -(29, 'seriesText', NULL, 0), -(30, 'seriesNumber', NULL, 0), -(31, 'institution', NULL, 0), -(32, 'reportType', NULL, 0), -(36, 'code', NULL, 0), -(40, 'session', NULL, 0), -(41, 'legislativeBody', NULL, 0), -(42, 'history', NULL, 0), -(43, 'reporter', NULL, 0), -(44, 'court', NULL, 0), -(45, 'numberOfVolumes', NULL, 0), -(46, 'committee', NULL, 0), -(48, 'assignee', NULL, 0), -(50, 'patentNumber', NULL, 0), -(51, 'priorityNumbers', NULL, 0), -(52, 'issueDate', NULL, 0), -(53, 'references', NULL, 0), -(54, 'legalStatus', NULL, 0), -(55, 'codeNumber', NULL, 0), -(59, 'artworkMedium', NULL, 0), -(60, 'number', NULL, 0), -(61, 'artworkSize', NULL, 0), -(62, 'libraryCatalog', NULL, 0), -(63, 'videoRecordingFormat', NULL, 0), -(64, 'interviewMedium', NULL, 0), -(65, 'letterType', NULL, 0), -(66, 'manuscriptType', NULL, 0), -(67, 'mapType', NULL, 0), -(68, 'scale', NULL, 0), -(69, 'thesisType', NULL, 0), -(70, 'websiteType', NULL, 0), -(71, 'audioRecordingFormat', NULL, 0), -(72, 'label', NULL, 0), -(74, 'presentationType', NULL, 0), -(75, 'meetingName', NULL, 0), -(76, 'studio', NULL, 0), -(77, 'runningTime', NULL, 0), -(78, 'network', NULL, 0), -(79, 'postType', NULL, 0), -(80, 'audioFileType', NULL, 0), -(81, 'versionNumber', NULL, 0), -(82, 'system', NULL, 0), -(83, 'company', NULL, 0), -(84, 'conferenceName', NULL, 0), -(85, 'encyclopediaTitle', NULL, 0), -(86, 'dictionaryTitle', NULL, 0), -(87, 'language', NULL, 0), -(88, 'programmingLanguage', NULL, 0), -(89, 'university', NULL, 0), -(90, 'abstractNote', NULL, 0), -(91, 'websiteTitle', NULL, 0), -(92, 'reportNumber', NULL, 0), -(93, 'billNumber', NULL, 0), -(94, 'codeVolume', NULL, 0), -(95, 'codePages', NULL, 0), -(96, 'dateDecided', NULL, 0), -(97, 'reporterVolume', NULL, 0), -(98, 'firstPage', NULL, 0), -(99, 'documentNumber', NULL, 0), -(100, 'dateEnacted', NULL, 0), -(101, 'publicLawNumber', NULL, 0), -(102, 'country', NULL, 0), -(103, 'applicationNumber', NULL, 0), -(104, 'forumTitle', NULL, 0), -(105, 'episodeNumber', NULL, 0), -(107, 'blogTitle', NULL, 0), -(108, 'type', NULL, 0), -(109, 'medium', NULL, 0), -(110, 'title', NULL, 0), -(111, 'caseName', NULL, 0), -(112, 'nameOfAct', NULL, 0), -(113, 'subject', NULL, 0), -(114, 'proceedingsTitle', NULL, 0), -(115, 'bookTitle', NULL, 0), -(116, 'shortTitle', NULL, 0), -(117, 'docketNumber', NULL, 0), -(118, 'numPages', NULL, 0), -(119, 'programTitle', NULL, 0), -(120, 'issuingAuthority', NULL, 0), -(121, 'filingDate', NULL, 0), -(122, 'genre', NULL, 0), -(123, 'archive', NULL, 0), -(10001, 'name', NULL, 1), -(10002, 'address', NULL, 1), -(10003, 'telephone', NULL, 1), -(10004, 'email', NULL, 1), -(10005, 'homepage', NULL, 1), -(10006, 'discipline', NULL, 1), -(10007, 'nsfID', NULL, 1), -(10008, 'dateSent', NULL, 1), -(10009, 'dateDue', NULL, 1), -(10010, 'accepted', NULL, 1), -(10011, 'programDirector', NULL, 1); - -INSERT INTO `itemTypeCreatorTypes` (`itemTypeID`, `creatorTypeID`, `primaryField`) VALUES -(2, 1, 1), -(2, 2, 0), -(2, 3, 0), -(2, 4, 0), -(2, 5, 0), -(3, 1, 1), -(3, 2, 0), -(3, 3, 0), -(3, 4, 0), -(3, 5, 0), -(3, 29, 0), -(4, 1, 1), -(4, 2, 0), -(4, 3, 0), -(4, 4, 0), -(4, 27, 0), -(5, 1, 1), -(5, 2, 0), -(5, 4, 0), -(5, 27, 0), -(6, 1, 1), -(6, 2, 0), -(6, 4, 0), -(6, 27, 0), -(7, 1, 1), -(7, 2, 0), -(8, 1, 1), -(8, 2, 0), -(8, 16, 0), -(9, 1, 1), -(9, 2, 0), -(9, 4, 0), -(10, 2, 0), -(10, 4, 0), -(10, 6, 1), -(10, 7, 0), -(11, 2, 0), -(11, 8, 1), -(11, 9, 0), -(11, 10, 0), -(12, 2, 0), -(12, 22, 1), -(13, 1, 1), -(13, 2, 0), -(13, 4, 0), -(15, 1, 1), -(15, 2, 0), -(15, 4, 0), -(15, 5, 0), -(16, 2, 0), -(16, 12, 1), -(16, 28, 0), -(17, 1, 1), -(17, 2, 0), -(17, 13, 0), -(18, 2, 1), -(19, 2, 0), -(19, 14, 1), -(19, 15, 0), -(20, 1, 1), -(20, 2, 0), -(21, 1, 1), -(21, 2, 0), -(21, 16, 0), -(22, 2, 0), -(22, 5, 0), -(22, 20, 1), -(23, 1, 1), -(23, 2, 0), -(23, 23, 0), -(24, 1, 1), -(24, 2, 0), -(24, 16, 0), -(25, 1, 1), -(25, 2, 0), -(26, 2, 0), -(26, 17, 1), -(26, 18, 0), -(26, 19, 0), -(27, 2, 0), -(27, 24, 1), -(28, 2, 0), -(28, 8, 1), -(28, 9, 0), -(28, 10, 0), -(28, 11, 0), -(29, 2, 0), -(29, 8, 1), -(29, 9, 0), -(29, 10, 0), -(29, 11, 0), -(29, 25, 0), -(30, 2, 0), -(30, 8, 1), -(30, 9, 0), -(30, 10, 0), -(30, 11, 0), -(30, 25, 0), -(31, 2, 0), -(31, 25, 0), -(31, 26, 1), -(32, 2, 0), -(32, 21, 1), -(33, 1, 1), -(33, 2, 0), -(33, 3, 0), -(33, 4, 0), -(33, 5, 0), -(34, 1, 1), -(34, 2, 0), -(34, 3, 0), -(34, 4, 0), -(34, 27, 0), -(35, 1, 1), -(35, 2, 0), -(35, 3, 0), -(35, 4, 0), -(35, 5, 0), -(36, 1, 1), -(36, 2, 0), -(36, 3, 0), -(36, 4, 0), -(36, 5, 0); - -INSERT INTO `itemTypeFields` (`itemTypeID`, `fieldID`, `hide`, `orderIndex`) VALUES -(2, 1, 0, 15), -(2, 2, 0, 21), -(2, 3, 0, 3), -(2, 4, 0, 5), -(2, 6, 0, 7), -(2, 7, 0, 8), -(2, 8, 0, 9), -(2, 11, 0, 13), -(2, 14, 0, 10), -(2, 18, 0, 20), -(2, 19, 0, 18), -(2, 22, 0, 22), -(2, 27, 0, 16), -(2, 30, 0, 4), -(2, 45, 0, 6), -(2, 62, 0, 19), -(2, 87, 0, 12), -(2, 90, 0, 2), -(2, 110, 0, 1), -(2, 116, 0, 14), -(2, 118, 0, 11), -(2, 123, 0, 17), -(3, 1, 0, 16), -(3, 2, 0, 22), -(3, 3, 0, 4), -(3, 4, 0, 6), -(3, 6, 0, 8), -(3, 7, 0, 9), -(3, 8, 0, 10), -(3, 10, 0, 12), -(3, 11, 0, 14), -(3, 14, 0, 11), -(3, 18, 0, 21), -(3, 19, 0, 19), -(3, 22, 0, 23), -(3, 27, 0, 17), -(3, 30, 0, 5), -(3, 45, 0, 7), -(3, 62, 0, 20), -(3, 87, 0, 13), -(3, 90, 0, 2), -(3, 110, 0, 1), -(3, 115, 0, 3), -(3, 116, 0, 15), -(3, 123, 0, 18), -(4, 1, 0, 16), -(4, 2, 0, 22), -(4, 3, 0, 8), -(4, 4, 0, 4), -(4, 5, 0, 5), -(4, 10, 0, 6), -(4, 12, 0, 3), -(4, 13, 0, 14), -(4, 14, 0, 7), -(4, 18, 0, 21), -(4, 19, 0, 19), -(4, 22, 0, 23), -(4, 25, 0, 11), -(4, 26, 0, 13), -(4, 27, 0, 17), -(4, 28, 0, 9), -(4, 29, 0, 10), -(4, 62, 0, 20), -(4, 87, 0, 12), -(4, 90, 0, 2), -(4, 110, 0, 1), -(4, 116, 0, 15), -(4, 123, 0, 18), -(5, 1, 0, 11), -(5, 2, 0, 17), -(5, 4, 0, 4), -(5, 5, 0, 5), -(5, 10, 0, 7), -(5, 12, 0, 3), -(5, 13, 0, 9), -(5, 14, 0, 6), -(5, 18, 0, 16), -(5, 19, 0, 14), -(5, 22, 0, 18), -(5, 27, 0, 12), -(5, 62, 0, 15), -(5, 87, 0, 8), -(5, 90, 0, 2), -(5, 110, 0, 1), -(5, 116, 0, 10), -(5, 123, 0, 13), -(6, 1, 0, 12), -(6, 2, 0, 18), -(6, 6, 0, 5), -(6, 7, 0, 4), -(6, 10, 0, 8), -(6, 12, 0, 3), -(6, 13, 0, 11), -(6, 14, 0, 6), -(6, 15, 0, 7), -(6, 18, 0, 17), -(6, 19, 0, 15), -(6, 22, 0, 19), -(6, 27, 0, 13), -(6, 62, 0, 16), -(6, 87, 0, 9), -(6, 90, 0, 2), -(6, 110, 0, 1), -(6, 116, 0, 10), -(6, 123, 0, 14), -(7, 1, 0, 10), -(7, 2, 0, 16), -(7, 7, 0, 5), -(7, 14, 0, 6), -(7, 18, 0, 15), -(7, 19, 0, 13), -(7, 22, 0, 17), -(7, 27, 0, 11), -(7, 62, 0, 14), -(7, 69, 0, 3), -(7, 87, 0, 8), -(7, 89, 0, 4), -(7, 90, 0, 2), -(7, 110, 0, 1), -(7, 116, 0, 9), -(7, 118, 0, 7), -(7, 123, 0, 12), -(8, 1, 0, 7), -(8, 2, 0, 13), -(8, 14, 0, 4), -(8, 18, 0, 12), -(8, 19, 0, 10), -(8, 22, 0, 14), -(8, 27, 0, 8), -(8, 62, 0, 11), -(8, 65, 0, 3), -(8, 87, 0, 5), -(8, 90, 0, 2), -(8, 110, 0, 1), -(8, 116, 0, 6), -(8, 123, 0, 9), -(9, 1, 0, 9), -(9, 2, 0, 15), -(9, 7, 0, 4), -(9, 14, 0, 5), -(9, 18, 0, 14), -(9, 19, 0, 12), -(9, 22, 0, 16), -(9, 27, 0, 10), -(9, 62, 0, 13), -(9, 66, 0, 3), -(9, 87, 0, 7), -(9, 90, 0, 2), -(9, 110, 0, 1), -(9, 116, 0, 8), -(9, 118, 0, 6), -(9, 123, 0, 11), -(10, 1, 0, 7), -(10, 2, 0, 13), -(10, 14, 0, 3), -(10, 18, 0, 12), -(10, 19, 0, 10), -(10, 22, 0, 14), -(10, 27, 0, 8), -(10, 62, 0, 11), -(10, 64, 0, 4), -(10, 87, 0, 5), -(10, 90, 0, 2), -(10, 110, 0, 1), -(10, 116, 0, 6), -(10, 123, 0, 9), -(11, 1, 0, 10), -(11, 2, 0, 16), -(11, 14, 0, 4), -(11, 18, 0, 15), -(11, 19, 0, 13), -(11, 21, 0, 3), -(11, 22, 0, 17), -(11, 27, 0, 11), -(11, 62, 0, 14), -(11, 63, 0, 6), -(11, 77, 0, 7), -(11, 87, 0, 8), -(11, 90, 0, 2), -(11, 110, 0, 1), -(11, 116, 0, 9), -(11, 122, 0, 5), -(11, 123, 0, 12), -(12, 1, 0, 12), -(12, 2, 0, 14), -(12, 14, 0, 5), -(12, 18, 0, 11), -(12, 19, 0, 9), -(12, 22, 0, 15), -(12, 27, 0, 13), -(12, 59, 0, 3), -(12, 61, 0, 4), -(12, 62, 0, 10), -(12, 87, 0, 6), -(12, 90, 0, 2), -(12, 110, 0, 1), -(12, 116, 0, 7), -(12, 123, 0, 8), -(13, 1, 0, 7), -(13, 2, 0, 10), -(13, 14, 0, 5), -(13, 22, 0, 11), -(13, 27, 0, 8), -(13, 70, 0, 4), -(13, 87, 0, 9), -(13, 90, 0, 2), -(13, 91, 0, 3), -(13, 110, 0, 1), -(13, 116, 0, 6), -(14, 1, 0, 3), -(14, 27, 0, 2), -(14, 110, 0, 1), -(15, 1, 0, 12), -(15, 2, 0, 18), -(15, 7, 0, 6), -(15, 10, 0, 9), -(15, 14, 0, 8), -(15, 18, 0, 17), -(15, 19, 0, 15), -(15, 22, 0, 19), -(15, 27, 0, 13), -(15, 28, 0, 5), -(15, 31, 0, 7), -(15, 32, 0, 4), -(15, 62, 0, 16), -(15, 87, 0, 10), -(15, 90, 0, 2), -(15, 92, 0, 3), -(15, 110, 0, 1), -(15, 116, 0, 11), -(15, 123, 0, 14), -(16, 1, 0, 13), -(16, 2, 0, 16), -(16, 14, 0, 11), -(16, 15, 0, 6), -(16, 22, 0, 17), -(16, 27, 0, 14), -(16, 36, 0, 4), -(16, 40, 0, 9), -(16, 41, 0, 8), -(16, 42, 0, 10), -(16, 87, 0, 12), -(16, 90, 0, 2), -(16, 93, 0, 3), -(16, 94, 0, 5), -(16, 95, 0, 7), -(16, 110, 0, 1), -(16, 116, 0, 15), -(17, 1, 0, 12), -(17, 2, 0, 14), -(17, 22, 0, 15), -(17, 27, 0, 13), -(17, 42, 0, 8), -(17, 43, 0, 3), -(17, 44, 0, 5), -(17, 87, 0, 10), -(17, 90, 0, 2), -(17, 96, 0, 9), -(17, 97, 0, 4), -(17, 98, 0, 7), -(17, 111, 0, 1), -(17, 116, 0, 11), -(17, 117, 0, 6), -(18, 1, 0, 15), -(18, 2, 0, 17), -(18, 7, 0, 4), -(18, 8, 0, 5), -(18, 10, 0, 8), -(18, 14, 0, 12), -(18, 22, 0, 18), -(18, 27, 0, 16), -(18, 40, 0, 10), -(18, 41, 0, 9), -(18, 42, 0, 11), -(18, 45, 0, 6), -(18, 46, 0, 3), -(18, 87, 0, 13), -(18, 90, 0, 2), -(18, 99, 0, 7), -(18, 110, 0, 1), -(18, 116, 0, 14), -(19, 1, 0, 17), -(19, 2, 0, 19), -(19, 7, 0, 3), -(19, 10, 0, 9), -(19, 22, 0, 20), -(19, 27, 0, 18), -(19, 48, 0, 5), -(19, 50, 0, 7), -(19, 51, 0, 11), -(19, 52, 0, 12), -(19, 53, 0, 13), -(19, 54, 0, 14), -(19, 87, 0, 15), -(19, 90, 0, 2), -(19, 102, 0, 4), -(19, 103, 0, 10), -(19, 110, 0, 1), -(19, 116, 0, 16), -(19, 120, 0, 6), -(19, 121, 0, 8), -(20, 1, 0, 13), -(20, 2, 0, 15), -(20, 10, 0, 7), -(20, 15, 0, 8), -(20, 22, 0, 16), -(20, 27, 0, 14), -(20, 36, 0, 3), -(20, 40, 0, 9), -(20, 42, 0, 10), -(20, 55, 0, 4), -(20, 87, 0, 11), -(20, 90, 0, 2), -(20, 100, 0, 6), -(20, 101, 0, 5), -(20, 112, 0, 1), -(20, 116, 0, 12), -(21, 1, 0, 5), -(21, 2, 0, 8), -(21, 14, 0, 3), -(21, 22, 0, 9), -(21, 27, 0, 6), -(21, 87, 0, 7), -(21, 90, 0, 2), -(21, 113, 0, 1), -(21, 116, 0, 4), -(22, 1, 0, 13), -(22, 2, 0, 19), -(22, 6, 0, 6), -(22, 7, 0, 7), -(22, 8, 0, 8), -(22, 11, 0, 11), -(22, 14, 0, 9), -(22, 18, 0, 18), -(22, 19, 0, 16), -(22, 22, 0, 20), -(22, 27, 0, 14), -(22, 28, 0, 5), -(22, 62, 0, 17), -(22, 67, 0, 3), -(22, 68, 0, 4), -(22, 87, 0, 10), -(22, 90, 0, 2), -(22, 110, 0, 1), -(22, 116, 0, 12), -(22, 123, 0, 15), -(23, 1, 0, 6), -(23, 2, 0, 10), -(23, 14, 0, 5), -(23, 22, 0, 11), -(23, 27, 0, 7), -(23, 70, 0, 4), -(23, 87, 0, 8), -(23, 90, 0, 2), -(23, 107, 0, 3), -(23, 110, 0, 1), -(23, 116, 0, 9), -(24, 1, 0, 6), -(24, 2, 0, 8), -(24, 14, 0, 3), -(24, 22, 0, 9), -(24, 27, 0, 7), -(24, 87, 0, 4), -(24, 90, 0, 2), -(24, 110, 0, 1), -(24, 116, 0, 5), -(25, 1, 0, 8), -(25, 2, 0, 10), -(25, 14, 0, 5), -(25, 22, 0, 11), -(25, 27, 0, 9), -(25, 79, 0, 4), -(25, 87, 0, 6), -(25, 90, 0, 2), -(25, 104, 0, 3), -(25, 110, 0, 1), -(25, 116, 0, 7), -(26, 1, 0, 18), -(26, 2, 0, 20), -(26, 4, 0, 5), -(26, 7, 0, 7), -(26, 11, 0, 12), -(26, 14, 0, 9), -(26, 18, 0, 17), -(26, 19, 0, 15), -(26, 22, 0, 21), -(26, 27, 0, 19), -(26, 28, 0, 4), -(26, 45, 0, 6), -(26, 62, 0, 16), -(26, 71, 0, 3), -(26, 72, 0, 8), -(26, 77, 0, 10), -(26, 87, 0, 11), -(26, 90, 0, 2), -(26, 110, 0, 1), -(26, 116, 0, 13), -(26, 123, 0, 14), -(27, 1, 0, 7), -(27, 2, 0, 11), -(27, 7, 0, 5), -(27, 14, 0, 4), -(27, 22, 0, 12), -(27, 27, 0, 8), -(27, 74, 0, 3), -(27, 75, 0, 6), -(27, 87, 0, 9), -(27, 90, 0, 2), -(27, 110, 0, 1), -(27, 116, 0, 10), -(28, 1, 0, 14), -(28, 2, 0, 20), -(28, 4, 0, 5), -(28, 7, 0, 7), -(28, 11, 0, 12), -(28, 14, 0, 9), -(28, 18, 0, 19), -(28, 19, 0, 17), -(28, 22, 0, 21), -(28, 27, 0, 15), -(28, 28, 0, 4), -(28, 45, 0, 6), -(28, 62, 0, 18), -(28, 63, 0, 3), -(28, 76, 0, 8), -(28, 77, 0, 10), -(28, 87, 0, 11), -(28, 90, 0, 2), -(28, 110, 0, 1), -(28, 116, 0, 13), -(28, 123, 0, 16), -(29, 1, 0, 12), -(29, 2, 0, 18), -(29, 7, 0, 6), -(29, 14, 0, 8), -(29, 18, 0, 17), -(29, 19, 0, 15), -(29, 22, 0, 19), -(29, 27, 0, 13), -(29, 62, 0, 16), -(29, 63, 0, 5), -(29, 77, 0, 9), -(29, 78, 0, 7), -(29, 87, 0, 10), -(29, 90, 0, 2), -(29, 105, 0, 4), -(29, 110, 0, 1), -(29, 116, 0, 11), -(29, 119, 0, 3), -(29, 123, 0, 14), -(30, 1, 0, 12), -(30, 2, 0, 18), -(30, 7, 0, 6), -(30, 14, 0, 8), -(30, 18, 0, 17), -(30, 19, 0, 15), -(30, 22, 0, 19), -(30, 27, 0, 13), -(30, 62, 0, 16), -(30, 71, 0, 5), -(30, 77, 0, 9), -(30, 78, 0, 7), -(30, 87, 0, 10), -(30, 90, 0, 2), -(30, 105, 0, 4), -(30, 110, 0, 1), -(30, 116, 0, 11), -(30, 119, 0, 3), -(30, 123, 0, 14), -(31, 1, 0, 7), -(31, 2, 0, 11), -(31, 22, 0, 12), -(31, 27, 0, 8), -(31, 28, 0, 3), -(31, 77, 0, 6), -(31, 80, 0, 5), -(31, 87, 0, 9), -(31, 90, 0, 2), -(31, 105, 0, 4), -(31, 110, 0, 1), -(31, 116, 0, 10), -(32, 1, 0, 12), -(32, 2, 0, 13), -(32, 7, 0, 7), -(32, 11, 0, 10), -(32, 14, 0, 5), -(32, 18, 0, 17), -(32, 19, 0, 15), -(32, 22, 0, 19), -(32, 27, 0, 18), -(32, 28, 0, 3), -(32, 62, 0, 16), -(32, 81, 0, 4), -(32, 82, 0, 6), -(32, 83, 0, 8), -(32, 88, 0, 9), -(32, 90, 0, 2), -(32, 110, 0, 1), -(32, 116, 0, 11), -(32, 123, 0, 14), -(33, 1, 0, 15), -(33, 2, 0, 21), -(33, 3, 0, 10), -(33, 4, 0, 8), -(33, 7, 0, 6), -(33, 8, 0, 7), -(33, 10, 0, 9), -(33, 11, 0, 13), -(33, 14, 0, 3), -(33, 18, 0, 20), -(33, 19, 0, 18), -(33, 22, 0, 22), -(33, 26, 0, 12), -(33, 27, 0, 16), -(33, 62, 0, 19), -(33, 84, 0, 5), -(33, 87, 0, 11), -(33, 90, 0, 2), -(33, 110, 0, 1), -(33, 114, 0, 4), -(33, 116, 0, 14), -(33, 123, 0, 17), -(34, 1, 0, 7), -(34, 2, 0, 13), -(34, 8, 0, 3), -(34, 14, 0, 4), -(34, 18, 0, 12), -(34, 19, 0, 10), -(34, 22, 0, 14), -(34, 27, 0, 8), -(34, 62, 0, 11), -(34, 87, 0, 5), -(34, 90, 0, 2), -(34, 110, 0, 1), -(34, 116, 0, 6), -(34, 123, 0, 9), -(35, 1, 0, 15), -(35, 2, 0, 22), -(35, 3, 0, 4), -(35, 4, 0, 6), -(35, 6, 0, 8), -(35, 7, 0, 9), -(35, 8, 0, 10), -(35, 10, 0, 12), -(35, 11, 0, 13), -(35, 14, 0, 11), -(35, 18, 0, 21), -(35, 19, 0, 19), -(35, 22, 0, 23), -(35, 27, 0, 16), -(35, 30, 0, 5), -(35, 45, 0, 7), -(35, 62, 0, 20), -(35, 85, 0, 3), -(35, 87, 0, 17), -(35, 90, 0, 2), -(35, 110, 0, 1), -(35, 116, 0, 14), -(35, 123, 0, 18), -(36, 1, 0, 16), -(36, 2, 0, 22), -(36, 3, 0, 4), -(36, 4, 0, 6), -(36, 6, 0, 8), -(36, 7, 0, 9), -(36, 8, 0, 10), -(36, 10, 0, 12), -(36, 11, 0, 14), -(36, 14, 0, 11), -(36, 18, 0, 21), -(36, 19, 0, 19), -(36, 22, 0, 23), -(36, 27, 0, 17), -(36, 30, 0, 5), -(36, 45, 0, 7), -(36, 62, 0, 20), -(36, 86, 0, 3), -(36, 87, 0, 13), -(36, 90, 0, 2), -(36, 110, 0, 1), -(36, 116, 0, 15), -(36, 123, 0, 18), -(10001, 31, 0, 4), -(10001, 10001, 0, 1), -(10001, 10002, 0, 2), -(10001, 10003, 0, 3), -(10001, 10004, 0, 5), -(10001, 10005, 0, 6), -(10001, 10006, 0, 7), -(10001, 10007, 0, 8), -(10001, 10008, 0, 9), -(10001, 10009, 0, 10), -(10001, 10010, 0, 11), -(10001, 10011, 0, 12); - -INSERT INTO `baseFieldMappings` (`itemTypeID`, `baseFieldID`, `fieldID`) VALUES -(10001, 1, 10005), -(16, 4, 94), -(17, 4, 97), -(7, 8, 89), -(11, 8, 21), -(15, 8, 31), -(26, 8, 72), -(28, 8, 76), -(30, 8, 78), -(32, 8, 83), -(16, 10, 95), -(17, 10, 98), -(3, 12, 115), -(13, 12, 91), -(23, 12, 107), -(25, 12, 104), -(29, 12, 119), -(30, 12, 119), -(33, 12, 114), -(35, 12, 85), -(36, 12, 86), -(17, 14, 96), -(19, 14, 52), -(20, 14, 100), -(10001, 14, 10008), -(15, 60, 92), -(16, 60, 93), -(17, 60, 117), -(18, 60, 99), -(19, 60, 50), -(20, 60, 101), -(29, 60, 105), -(30, 60, 105), -(31, 60, 105), -(7, 108, 69), -(8, 108, 65), -(9, 108, 66), -(11, 108, 122), -(13, 108, 70), -(15, 108, 32), -(22, 108, 67), -(23, 108, 70), -(25, 108, 79), -(27, 108, 74), -(10, 109, 64), -(11, 109, 63), -(12, 109, 59), -(26, 109, 71), -(28, 109, 63), -(29, 109, 63), -(30, 109, 71), -(31, 109, 80), -(17, 110, 111), -(20, 110, 112), -(21, 110, 113), -(10001, 110, 10001); - -INSERT INTO `charsets` (`charsetID`, `charset`) VALUES -(50, 'adobe-symbol-encoding'), -(51, 'ansi_x3.4-1968'), -(52, 'ansi_x3.4-1986'), -(2, 'ascii'), -(53, 'big5'), -(54, 'chinese'), -(55, 'cn-big5'), -(56, 'cn-gb'), -(57, 'cn-gb-isoir165'), -(58, 'cp367'), -(59, 'cp819'), -(60, 'cp850'), -(61, 'cp852'), -(62, 'cp855'), -(63, 'cp857'), -(64, 'cp862'), -(65, 'cp864'), -(66, 'cp866'), -(67, 'csascii'), -(68, 'csbig5'), -(69, 'cseuckr'), -(70, 'cseucpkdfmtjapanese'), -(71, 'csgb2312'), -(72, 'cshalfwidthkatakana'), -(73, 'cshppsmath'), -(74, 'csiso103t618bit'), -(75, 'csiso159jisx02121990'), -(76, 'csiso2022jp'), -(77, 'csiso2022jp2'), -(78, 'csiso2022kr'), -(79, 'csiso58gb231280'), -(80, 'csisolatin4'), -(81, 'csisolatincyrillic'), -(82, 'csisolatingreek'), -(83, 'cskoi8r'), -(84, 'csksc56011987'), -(85, 'csshiftjis'), -(86, 'csunicode11'), -(87, 'csunicode11utf7'), -(88, 'csunicodeascii'), -(89, 'csunicodelatin1'), -(90, 'cswindows31latin5'), -(91, 'cyrillic'), -(92, 'ecma-118'), -(93, 'elot_928'), -(94, 'euc-jp'), -(95, 'euc-kr'), -(96, 'extended_unix_code_packed_format_for_japanese'), -(97, 'gb2312'), -(98, 'gb_2312-80'), -(99, 'greek'), -(100, 'greek8'), -(101, 'hz-gb-2312'), -(102, 'ibm367'), -(103, 'ibm819'), -(104, 'ibm850'), -(105, 'ibm852'), -(106, 'ibm855'), -(107, 'ibm857'), -(108, 'ibm862'), -(109, 'ibm864'), -(110, 'ibm866'), -(111, 'iso-10646'), -(112, 'iso-10646-j-1'), -(113, 'iso-10646-ucs-2'), -(114, 'iso-10646-ucs-4'), -(115, 'iso-10646-ucs-basic'), -(116, 'iso-10646-unicode-latin1'), -(117, 'iso-2022-jp'), -(118, 'iso-2022-jp-2'), -(119, 'iso-2022-kr'), -(25, 'iso-8859-1'), -(26, 'iso-8859-1-windows-3.0-latin-1'), -(27, 'iso-8859-1-windows-3.1-latin-1'), -(28, 'iso-8859-15'), -(29, 'iso-8859-2'), -(30, 'iso-8859-2-windows-latin-2'), -(31, 'iso-8859-3'), -(32, 'iso-8859-4'), -(33, 'iso-8859-5'), -(34, 'iso-8859-5-windows-latin-5'), -(35, 'iso-8859-6'), -(36, 'iso-8859-7'), -(37, 'iso-8859-8'), -(38, 'iso-8859-9'), -(120, 'iso-ir-100'), -(121, 'iso-ir-101'), -(122, 'iso-ir-103'), -(123, 'iso-ir-110'), -(124, 'iso-ir-126'), -(125, 'iso-ir-144'), -(126, 'iso-ir-149'), -(127, 'iso-ir-159'), -(128, 'iso-ir-58'), -(129, 'iso-ir-6'), -(130, 'iso646-us'), -(12, 'iso8859-1'), -(13, 'iso8859-15'), -(14, 'iso_646.irv:1991'), -(15, 'iso_8859-1'), -(16, 'iso_8859-1:1987'), -(17, 'iso_8859-2'), -(18, 'iso_8859-2:1987'), -(19, 'iso_8859-4'), -(20, 'iso_8859-4:1988'), -(21, 'iso_8859-5'), -(22, 'iso_8859-5:1988'), -(23, 'iso_8859-7'), -(24, 'iso_8859-7:1987'), -(131, 'jis_x0201'), -(132, 'jis_x0208-1983'), -(133, 'jis_x0212-1990'), -(134, 'koi8-r'), -(135, 'korean'), -(139, 'ksc5601'), -(140, 'ksc_5601'), -(136, 'ks_c_5601'), -(137, 'ks_c_5601-1987'), -(138, 'ks_c_5601-1989'), -(39, 'l1'), -(40, 'l2'), -(41, 'l4'), -(42, 'latin1'), -(43, 'latin2'), -(44, 'latin4'), -(141, 'ms_kanji'), -(142, 'shift_jis'), -(143, 't.61'), -(144, 't.61-8bit'), -(145, 'unicode-1-1-utf-7'), -(146, 'unicode-1-1-utf-8'), -(147, 'unicode-2-0-utf-7'), -(9, 'us'), -(10, 'us-ascii'), -(11, 'utf-7'), -(1, 'utf-8'), -(3, 'windows-1250'), -(4, 'windows-1251'), -(5, 'windows-1252'), -(6, 'windows-1253'), -(7, 'windows-1254'), -(8, 'windows-1257'), -(148, 'windows-31j'), -(149, 'x-cns11643-1'), -(150, 'x-cns11643-1110'), -(151, 'x-cns11643-2'), -(152, 'x-cp1250'), -(153, 'x-cp1251'), -(154, 'x-cp1253'), -(155, 'x-dectech'), -(156, 'x-dingbats'), -(157, 'x-euc-jp'), -(158, 'x-euc-tw'), -(159, 'x-gb2312-11'), -(160, 'x-imap4-modified-utf7'), -(161, 'x-jisx0208-11'), -(162, 'x-ksc5601-11'), -(45, 'x-mac-ce'), -(46, 'x-mac-cyrillic'), -(47, 'x-mac-greek'), -(48, 'x-mac-roman'), -(49, 'x-mac-turkish'), -(163, 'x-sjis'), -(164, 'x-tis620'), -(165, 'x-unicode-2-0-utf-7'), -(166, 'x-x-big5'), -(167, 'x0201'), -(168, 'x0212'); - - -COMMIT; diff --git a/misc/db-updates/2017-07-25/removeGroupPublicationsItems b/misc/db-updates/2017-07-25/removeGroupPublicationsItems deleted file mode 100755 index 46670d79..00000000 --- a/misc/db-updates/2017-07-25/removeGroupPublicationsItems +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/php -d mysqlnd.net_read_timeout=3600 -= ? ORDER BY shardID", $startShard); -foreach ($shardIDs as $shardID) { - echo "Shard: $shardID\n"; - - $rows = Zotero_DB::query( - "SELECT libraryID, itemID FROM items JOIN publicationsItems USING (itemID) " - . "WHERE libraryID IN (SELECT libraryID FROM shardLibraries WHERE libraryType='group') ORDER BY libraryID, itemID", - false, - $shardID - ); - $lastLibraryID = null; - foreach ($rows as $row) { - echo "Found bad item " . $row['itemID'] . "\n"; - Zotero_DB::query("DELETE FROM publicationsItems WHERE itemID=?", $row['itemID'], $shardID); - - // If done with a library, update its version - if ($lastLibraryID && $row['libraryID'] != $lastLibraryID) { - echo "Updating library " . $row['libraryID'] . "\n"; - Zotero_DB::query("UPDATE shardLibraries SET version=version+1 WHERE libraryID=?", $lastLibraryID, $shardID); - } - $lastLibraryID = $row['libraryID']; - } - if ($lastLibraryID) { - echo "Updating library $lastLibraryID\n"; - Zotero_DB::query("UPDATE shardLibraries SET version=version+1 WHERE libraryID=?", $lastLibraryID, $shardID); - } -} diff --git a/misc/db-updates/2017-09-21/addItemsServerDateModifiedIndex b/misc/db-updates/2017-09-21/addItemsServerDateModifiedIndex deleted file mode 100755 index 3a3892f3..00000000 --- a/misc/db-updates/2017-09-21/addItemsServerDateModifiedIndex +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/php -d mysqlnd.net_read_timeout=3600 -= ? ORDER BY shardID", $startShard); -foreach ($shardIDs as $shardID) { - echo "Shard: $shardID\n"; - - Zotero_Admin_DB::query("ALTER TABLE `items` ADD INDEX (`serverDateModified`)", false, $shardID); -} diff --git a/misc/db-updates/2017-10-17/emojiSearchNames b/misc/db-updates/2017-10-17/emojiSearchNames deleted file mode 100755 index 179ab534..00000000 --- a/misc/db-updates/2017-10-17/emojiSearchNames +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/php -d mysqlnd.net_read_timeout=3600 -= ? ORDER BY shardID", $startShard); -foreach ($shardIDs as $shardID) { - echo "Shard: $shardID\n"; - - Zotero_Admin_DB::query("ALTER TABLE `savedSearches` CHANGE `searchName` `searchName` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL", false, $shardID); -} diff --git a/misc/db-updates/2017-12-09/mb4Update b/misc/db-updates/2017-12-09/mb4Update deleted file mode 100755 index ca0a5b61..00000000 --- a/misc/db-updates/2017-12-09/mb4Update +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/php -d mysqlnd.net_read_timeout=3600 -= ? ORDER BY shardID", $startShard); -foreach ($shardIDs as $shardID) { - echo "Shard: $shardID\n"; - - // Requires Barracuda file format for indexes >767 bytes - Zotero_Admin_DB::query("ALTER TABLE tags ROW_FORMAT=DYNAMIC", false, $shardID); - Zotero_Admin_DB::query("ALTER TABLE syncDeleteLogKeys ROW_FORMAT=DYNAMIC", false, $shardID); - Zotero_Admin_DB::query("ALTER TABLE tags CHANGE `name` `name` VARCHAR( 255 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL", false, $shardID); - Zotero_Admin_DB::query("ALTER TABLE syncDeleteLogKeys CHANGE `key` `key` VARCHAR( 255 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL", false, $shardID); - - Zotero_Admin_DB::query("ALTER TABLE `itemSortFields` CHANGE `sortTitle` `sortTitle` VARCHAR( 79 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NULL DEFAULT NULL, CHANGE `creatorSummary` `creatorSummary` VARCHAR( 50 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NULL DEFAULT NULL", false, $shardID); - - Zotero_Admin_DB::query("ALTER TABLE `collections` CHANGE `collectionName` `collectionName` VARCHAR( 255 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL", false, $shardID); - Zotero_Admin_DB::query("ALTER TABLE `creators` CHANGE `firstName` `firstName` VARCHAR( 255 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NULL DEFAULT NULL, CHANGE `lastName` `lastName` VARCHAR( 255 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NULL DEFAULT NULL", false, $shardID); - Zotero_Admin_DB::query("ALTER TABLE `itemData` CHANGE `value` `value` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NULL DEFAULT NULL", false, $shardID); - Zotero_Admin_DB::query("ALTER TABLE `itemNotes` CHANGE `note` `note` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL, CHANGE `noteSanitized` `noteSanitized` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NULL DEFAULT NULL, CHANGE `title` `title` VARCHAR( 80 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL", false, $shardID); - Zotero_Admin_DB::query("ALTER TABLE `savedSearchConditions` CHANGE `value` `value` VARCHAR( 255 ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci NOT NULL", false, $shardID); - Zotero_Admin_DB::query("ALTER TABLE `settings` CHANGE `value` `value` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL", false, $shardID); -} diff --git a/misc/db-updates/2018-07-14/storageFileLibraries_populate b/misc/db-updates/2018-07-14/storageFileLibraries_populate deleted file mode 100755 index ee6cf7cd..00000000 --- a/misc/db-updates/2018-07-14/storageFileLibraries_populate +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/php -d mysqlnd.net_read_timeout=3600 -= ? ORDER BY shardID", $startShard); -foreach ($shardIDs as $shardID) { - echo "Shard: $shardID\n"; - - $lastLibraryID = 0; - $lastStorageFileID = 0; - $batchSize = 1000; - - while (true) { - // Get storageFileID/library mappings from shard in batches - $rows = Zotero_DB::query( - "SELECT storageFileID, libraryID FROM storageFileItems JOIN items USING (itemID) " - . "WHERE storageFileID >= ? AND libraryID >= ? " - . "ORDER BY storageFileID, libraryID LIMIT ?", - [$lastStorageFileID, $lastLibraryID, $batchSize], - $shardID - ); - if (!$rows) { - break; - } - - // Populate storageFileLibraries - try { - $affectedRows = Zotero_DB::query( - "INSERT IGNORE INTO storageFileLibraries (storageFileID, libraryID) VALUES " - . implode(", ", array_map(function ($row) { - return "({$row['storageFileID']}, {$row['libraryID']})"; - }, $rows)) - ); - } - catch (Exception $e) { - // If storageFileID is missing on master, delete offending storageFileItems rows and continue - if (strpos($e->getMessage(), "FOREIGN KEY (`storageFileID`)") !== false) { - foreach ($rows as $row) { - if (!Zotero_DB::valueQuery("SELECT COUNT(*) FROM storageFiles WHERE storageFileID=?", $row['storageFileID'])) { - echo "Storage file {$row['storageFileID']} not found on master -- deleting from storageFileItems\n"; - Zotero_DB::query("DELETE FROM storageFileItems WHERE storageFileID=?", $row['storageFileID'], $shardID); - continue 2; - } - } - } - throw $e; - } - if ($affectedRows) { - echo "Added $affectedRows rows\n"; - } - - $lastRow = $rows[sizeOf($rows) - 1]; - if ($lastRow['libraryID'] == $lastLibraryID && $lastRow['storageFileID'] == $lastStorageFileID) { - break; - } - $lastLibraryID = $lastRow['libraryID']; - $lastStorageFileID = $lastRow['storageFileID']; - } -} diff --git a/misc/elasticsearch/.gitignore b/misc/elasticsearch/.gitignore deleted file mode 100644 index 52e2b8de..00000000 --- a/misc/elasticsearch/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -init.json -settings.json -aliases.json diff --git a/misc/elasticsearch/aliases.json-sample b/misc/elasticsearch/aliases.json-sample deleted file mode 100644 index 8d5386c9..00000000 --- a/misc/elasticsearch/aliases.json-sample +++ /dev/null @@ -1,14 +0,0 @@ -{ - "actions": [ - { - "add": { - "alias": "item_fulltext_index_read", - "index": "item_fulltext_index_a" - }, - "add": { - "alias": "item_fulltext_index_write", - "index": "item_fulltext_index_a" - } - } - ] -} diff --git a/misc/elasticsearch/bin/init b/misc/elasticsearch/bin/init deleted file mode 100755 index 57eaa78d..00000000 --- a/misc/elasticsearch/bin/init +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -if [ -z "$1" ] || [ -z "$2" ]; then - echo "Usage: $0 host index_suffix" - exit -fi -HOST="$1" -INDEX_SUFFIX="$2" - -# Get path to script's parent directory -DIR=$(cd ${0%/*} && echo $PWD/${0##*/}) -DIR=`dirname $DIR` -DIR=`dirname $DIR` -ITEM_FULLTEXT_DIR=$DIR/item_fulltext - -curl -XPUT "http://$HOST:9200/item_fulltext_index_$INDEX_SUFFIX" -d @$ITEM_FULLTEXT_DIR/init.json -echo - -$DIR/bin/update-settings $HOST $INDEX_SUFFIX - -curl -XPUT "http://$HOST:9200/item_fulltext_index_$INDEX_SUFFIX/item_fulltext/_mapping" -d @$ITEM_FULLTEXT_DIR/mapping.json -echo - -curl -XPOST "http://$HOST:9200/_aliases" -d @aliases.json -echo diff --git a/misc/elasticsearch/bin/update-aliases b/misc/elasticsearch/bin/update-aliases deleted file mode 100755 index 16a8500e..00000000 --- a/misc/elasticsearch/bin/update-aliases +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh - -if [ -z "$1" ]; then - echo "Usage: $0 host" - exit -fi -HOST="$1" - -# Get path to script's parent directory -DIR=$(cd ${0%/*} && echo $PWD/${0##*/}) -DIR=`dirname $DIR` -DIR=`dirname $DIR` - -curl -XPOST "http://$HOST:9200/_aliases" -d @$DIR/aliases.json -echo diff --git a/misc/elasticsearch/bin/update-settings b/misc/elasticsearch/bin/update-settings deleted file mode 100755 index e01d297f..00000000 --- a/misc/elasticsearch/bin/update-settings +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh - -if [ -z "$1" ] || [ -z "$2" ]; then - echo "Usage: $0 host index_suffix" - exit -fi -HOST="$1" -INDEX_SUFFIX="$2" - -# Get path to script's parent directory -DIR=$(cd ${0%/*} && echo $PWD/${0##*/}) -DIR=`dirname $DIR` -DIR=`dirname $DIR` -ITEM_FULLTEXT_DIR=$DIR/item_fulltext - -curl -XPUT "http://$HOST:9200/item_fulltext_index_$INDEX_SUFFIX/_settings" -d @$ITEM_FULLTEXT_DIR/settings.json -echo diff --git a/misc/elasticsearch/item_fulltext/init.json-sample b/misc/elasticsearch/item_fulltext/init.json-sample deleted file mode 100644 index c2feb57c..00000000 --- a/misc/elasticsearch/item_fulltext/init.json-sample +++ /dev/null @@ -1,7 +0,0 @@ -{ - "settings": { - "number_of_shards": 1, - "number_of_replicas": 1, - "query.default_field": "content" - } -} diff --git a/misc/elasticsearch/item_fulltext/mapping.json b/misc/elasticsearch/item_fulltext/mapping.json deleted file mode 100644 index d095adfd..00000000 --- a/misc/elasticsearch/item_fulltext/mapping.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "item_fulltext": { - "properties": { - "libraryID": { "type": "integer" }, - "content": { "type": "string" }, - "version": { "type": "integer" }, - "timestamp": { "type": "date", "format": "dateHourMinuteSecond" }, - "indexedChars": { "type": "integer", "index" : "no" }, - "totalChars": { "type": "integer", "index" : "no" }, - "indexedPages": { "type": "integer", "index" : "no" }, - "totalPages": { "type": "integer", "index" : "no" } - }, - "_all": { - "enabled": false - }, - "_routing": { - "required": true, - "path": "libraryID" - }, - "dynamic" : false - } -} diff --git a/misc/elasticsearch/item_fulltext/settings.json-sample b/misc/elasticsearch/item_fulltext/settings.json-sample deleted file mode 100644 index 0b28b3c1..00000000 --- a/misc/elasticsearch/item_fulltext/settings.json-sample +++ /dev/null @@ -1,5 +0,0 @@ -{ - "index": { - "refresh_interval": "5s" - } -} diff --git a/misc/fulltext.sql b/misc/fulltext.sql deleted file mode 100644 index 8479645b..00000000 --- a/misc/fulltext.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE IF NOT EXISTS `fulltextContent` ( - `libraryID` int(10) unsigned NOT NULL, - `key` char(8) CHARACTER SET ascii NOT NULL, - `content` mediumtext NOT NULL, - `indexedChars` int unsigned NOT NULL DEFAULT '0', - `totalChars` int unsigned NOT NULL DEFAULT '0', - `indexedPages` smallint(5) unsigned NOT NULL DEFAULT '0', - `totalPages` smallint(5) unsigned NOT NULL DEFAULT '0', - `version` int(10) unsigned NOT NULL DEFAULT '0', - `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`libraryID`,`key`), - FULLTEXT KEY `content` (`content`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/misc/ids.sql b/misc/ids.sql deleted file mode 100644 index 00649121..00000000 --- a/misc/ids.sql +++ /dev/null @@ -1,43 +0,0 @@ --- From http://code.flickr.com/blog/2010/02/08/ticket-servers-distributed-unique-primary-keys-on-the-cheap/ - -CREATE TABLE `collections` ( - `id` int(10) unsigned NOT NULL auto_increment, - `stub` char(1) NOT NULL default '', - PRIMARY KEY (`id`), - UNIQUE KEY `stub` (`stub`) -) ENGINE=MyISAM; - -CREATE TABLE `creators` ( - `id` int(10) unsigned NOT NULL auto_increment, - `stub` char(1) NOT NULL default '', - PRIMARY KEY (`id`), - UNIQUE KEY `stub` (`stub`) -) ENGINE=MyISAM; - -CREATE TABLE `items` ( - `id` int(10) unsigned NOT NULL auto_increment, - `stub` char(1) NOT NULL default '', - PRIMARY KEY (`id`), - UNIQUE KEY `stub` (`stub`) -) ENGINE=MyISAM; - -CREATE TABLE `relations` ( - `id` int(10) unsigned NOT NULL auto_increment, - `stub` char(1) NOT NULL default '', - PRIMARY KEY (`id`), - UNIQUE KEY `stub` (`stub`) -) ENGINE=MyISAM; - -CREATE TABLE `savedSearches` ( - `id` int(10) unsigned NOT NULL auto_increment, - `stub` char(1) NOT NULL default '', - PRIMARY KEY (`id`), - UNIQUE KEY `stub` (`stub`) -) ENGINE=MyISAM; - -CREATE TABLE `tags` ( - `id` int(10) unsigned NOT NULL auto_increment, - `stub` char(1) NOT NULL default '', - PRIMARY KEY (`id`), - UNIQUE KEY `stub` (`stub`) -) ENGINE=MyISAM; diff --git a/misc/master.sql b/misc/master.sql deleted file mode 100644 index 80482f11..00000000 --- a/misc/master.sql +++ /dev/null @@ -1,569 +0,0 @@ --- ***** BEGIN LICENSE BLOCK ***** --- --- This file is part of the Zotero Data Server. --- --- Copyright © 2010 Center for History and New Media --- George Mason University, Fairfax, Virginia, USA --- http://zotero.org --- --- This program is free software: you can redistribute it and/or modify --- it under the terms of the GNU Affero General Public License as published by --- the Free Software Foundation, either version 3 of the License, or --- (at your option) any later version. --- --- This program is distributed in the hope that it will be useful, --- but WITHOUT ANY WARRANTY; without even the implied warranty of --- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the --- GNU Affero General Public License for more details. --- --- You should have received a copy of the GNU Affero General Public License --- along with this program. If not, see . --- --- ***** END LICENSE BLOCK ***** - -SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO"; - -CREATE TABLE `abstractCreators` ( - `creatorID` int(10) unsigned NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `abstractItems` ( - `itemID` int(10) unsigned NOT NULL AUTO_INCREMENT, - PRIMARY KEY (`itemID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `baseFieldMappings` ( - `itemTypeID` smallint(5) unsigned NOT NULL, - `baseFieldID` smallint(5) unsigned NOT NULL, - `fieldID` smallint(5) unsigned NOT NULL, - PRIMARY KEY (`itemTypeID`,`baseFieldID`,`fieldID`), - KEY `baseFieldID` (`baseFieldID`), - KEY `fieldID` (`fieldID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `charsets` ( - `charsetID` tinyint(3) unsigned NOT NULL, - `charset` varchar(50) NOT NULL, - PRIMARY KEY (`charsetID`), - KEY `charset` (`charset`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `creatorTypes` ( - `creatorTypeID` smallint(5) unsigned NOT NULL, - `creatorTypeName` varchar(50) NOT NULL, - `custom` tinyint(1) unsigned NOT NULL, - PRIMARY KEY (`creatorTypeID`), - UNIQUE KEY `creatorTypeName` (`creatorTypeName`), - KEY `custom` (`custom`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `fields` ( - `fieldID` smallint(5) unsigned NOT NULL, - `fieldName` varchar(50) NOT NULL, - `fieldFormatID` tinyint(3) unsigned DEFAULT NULL, - `custom` tinyint(1) unsigned NOT NULL, - PRIMARY KEY (`fieldID`), - UNIQUE KEY `fieldName` (`fieldName`), - KEY `custom` (`custom`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `groups` ( - `groupID` int(10) unsigned NOT NULL AUTO_INCREMENT, - `libraryID` int(10) unsigned NOT NULL, - `name` varchar(255) NOT NULL, - `slug` varchar(255) DEFAULT NULL, - `type` enum('PublicOpen','PublicClosed','Private') NOT NULL DEFAULT 'Private', - `libraryEditing` enum('admins','members') NOT NULL DEFAULT 'admins', - `libraryReading` enum('members','all') NOT NULL DEFAULT 'all', - `fileEditing` enum('none','admins','members') NOT NULL DEFAULT 'admins', - `description` text NOT NULL, - `url` varchar(255) NOT NULL, - `hasImage` tinyint(1) unsigned NOT NULL DEFAULT '0', - `dateAdded` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `dateModified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - `version` mediumint(8) unsigned NOT NULL DEFAULT '1', - PRIMARY KEY (`groupID`), - UNIQUE KEY `libraryID` (`libraryID`), - UNIQUE KEY `slug` (`slug`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `groupUsers` ( - `groupID` int(10) unsigned NOT NULL, - `userID` int(10) unsigned NOT NULL, - `role` enum('owner','admin','member') NOT NULL DEFAULT 'member', - `joined` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - `lastUpdated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`groupID`,`userID`), - KEY `userID` (`userID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `itemTypeCreatorTypes` ( - `itemTypeID` smallint(5) unsigned NOT NULL, - `creatorTypeID` smallint(5) unsigned NOT NULL, - `primaryField` tinyint(1) unsigned NOT NULL, - PRIMARY KEY (`itemTypeID`,`creatorTypeID`), - KEY `creatorTypeID` (`creatorTypeID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `itemTypeFields` ( - `itemTypeID` smallint(5) unsigned NOT NULL, - `fieldID` smallint(5) unsigned NOT NULL, - `hide` tinyint(3) unsigned NOT NULL DEFAULT '0', - `orderIndex` tinyint(3) unsigned NOT NULL, - PRIMARY KEY (`itemTypeID`,`fieldID`), - KEY `fieldID` (`fieldID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `itemTypes` ( - `itemTypeID` smallint(5) unsigned NOT NULL, - `itemTypeName` varchar(50) NOT NULL, - `custom` tinyint(1) unsigned NOT NULL, - PRIMARY KEY (`itemTypeID`), - UNIQUE KEY `itemTypeName` (`itemTypeName`), - KEY `custom` (`custom`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE IF NOT EXISTS `keyAccessLog` ( - `keyID` int(10) unsigned NOT NULL, - `ipAddress` int(10) unsigned NOT NULL DEFAULT '0', - `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`keyID`,`ipAddress`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - - -CREATE TABLE `keyPermissions` ( - `keyID` int(10) unsigned NOT NULL, - `libraryID` int(10) unsigned NOT NULL, - `permission` enum('library','notes','write') NOT NULL, - `granted` tinyint(1) unsigned NOT NULL, - PRIMARY KEY (`keyID`,`libraryID`,`permission`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `keys` ( - `keyID` int(10) unsigned NOT NULL AUTO_INCREMENT, - `key` char(24) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, - `userID` int(10) unsigned NOT NULL, - `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL, - `dateAdded` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `lastUsed` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - PRIMARY KEY (`keyID`), - UNIQUE KEY `key` (`key`), - KEY `userID` (`userID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `libraries` ( - `libraryID` int(10) unsigned NOT NULL AUTO_INCREMENT, - `libraryType` enum('user','group','publications') NOT NULL, - `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, - PRIMARY KEY (`libraryID`), - KEY `shardID` (`shardID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - -CREATE TABLE `processorDaemons` ( - `mode` enum('download','upload','error','index') NOT NULL, - `addr` int(10) unsigned NOT NULL, - `port` smallint(5) unsigned NOT NULL, - `lastSeen` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`mode`,`addr`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `sessions` ( - `sessionID` char(32) CHARACTER SET ascii NOT NULL, - `userID` int(10) unsigned NOT NULL, - `ipAddress` int(10) unsigned DEFAULT NULL, - `exclusive` tinyint(1) unsigned NOT NULL DEFAULT '0', - `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`sessionID`), - KEY `userID` (`userID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `shardHostReplicas` ( - `shardHostID` tinyint(3) unsigned NOT NULL, - `address` varchar(75) NOT NULL, - `port` smallint(5) unsigned NOT NULL DEFAULT '3306', - `secure` int(1) unsigned NOT NULL DEFAULT '0', - `state` enum('up','down') NOT NULL, - PRIMARY KEY (`shardHostID`,`address`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - -CREATE TABLE `shardHosts` ( - `shardHostID` tinyint(3) unsigned NOT NULL, - `address` varchar(15) NOT NULL, - `port` smallint(5) unsigned NOT NULL DEFAULT 3306, - `state` enum('up','readonly','down') NOT NULL, - PRIMARY KEY (`shardHostID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `shards` ( - `shardID` smallint(5) unsigned NOT NULL, - `shardHostID` tinyint(3) unsigned NOT NULL, - `db` varchar(20) NOT NULL, - `state` enum('up','readonly','down') NOT NULL, - `items` mediumint(8) unsigned NOT NULL DEFAULT '0', - PRIMARY KEY (`shardID`), - KEY `shardHostID` (`shardHostID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `storageAccounts` ( - `userID` int(10) unsigned NOT NULL, - `quota` mediumint(8) unsigned NOT NULL, - `expiration` timestamp NULL DEFAULT '0000-00-00 00:00:00', - PRIMARY KEY (`userID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `storageDownloadLog` ( - `ownerUserID` int(10) unsigned NOT NULL, - `downloadUserID` int(10) unsigned DEFAULT NULL, - `ipAddress` int(10) unsigned NULL, - `storageFileID` int(10) unsigned NOT NULL, - `filename` varchar(1024) NOT NULL, - `size` int(10) unsigned NOT NULL, - `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `storageFiles` ( - `storageFileID` int(10) unsigned NOT NULL AUTO_INCREMENT, - `hash` char(32) NOT NULL, - `filename` varchar(255) NOT NULL, - `size` int(10) unsigned NOT NULL, - `zip` tinyint(1) unsigned NOT NULL, - `lastAdded` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`storageFileID`), - UNIQUE KEY `hash` (`hash`,`filename`,`zip`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `storageFileLibraries` ( - `storageFileID` int(10) unsigned NOT NULL, - `libraryID` int(10) unsigned NOT NULL, - PRIMARY KEY (`storageFileID`,`libraryID`), - KEY `libraryID` (`libraryID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `storageFilesExisting` ( - `storageFileID` int(10) unsigned NOT NULL, - PRIMARY KEY (`storageFileID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `storageLastSync` ( - `userID` int(10) unsigned NOT NULL, - `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`userID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `storageUploadLog` ( - `ownerUserID` int(10) unsigned NOT NULL, - `uploadUserID` int(10) unsigned NOT NULL, - `ipAddress` int(10) unsigned NULL, - `storageFileID` int(10) unsigned NOT NULL, - `filename` varchar(1024) NOT NULL, - `size` int(10) unsigned NOT NULL, - `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `storageUploadQueue` ( - `uploadKey` char(32) NOT NULL, - `userID` int(10) unsigned NOT NULL, - `hash` char(32) NOT NULL, - `filename` varchar(1024) NOT NULL, - `zip` tinyint(1) unsigned NOT NULL, - `itemHash` varchar(32) NOT NULL, - `itemFilename` varchar(1024) NOT NULL, - `size` int(10) unsigned NOT NULL, - `mtime` bigint(13) unsigned NOT NULL, - `contentType` varchar(75) DEFAULT NULL, - `charset` varchar(25) DEFAULT NULL, - `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`uploadKey`), - KEY `userID` (`userID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `syncDownloadProcessLog` ( - `userID` int(10) unsigned NOT NULL, - `lastsync` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - `objects` int(10) unsigned NOT NULL, - `ipAddress` int(10) unsigned NOT NULL, - `processorHost` int(10) unsigned NOT NULL, - `processDuration` float(6,2) NOT NULL, - `totalDuration` smallint(5) unsigned NOT NULL, - `error` tinyint(4) NOT NULL DEFAULT '0', - `finished` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - KEY `finished` (`finished`), - KEY `userID` (`userID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `syncDownloadQueue` ( - `syncDownloadQueueID` int(10) unsigned NOT NULL, - `processorHost` int(10) unsigned DEFAULT NULL, - `userID` int(10) unsigned NOT NULL, - `sessionID` char(32) CHARACTER SET ascii NOT NULL, - `lastsync` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - `lastsyncMS` smallint(5) unsigned NOT NULL DEFAULT '0', - `version` smallint(5) unsigned NOT NULL, - `params` mediumtext NOT NULL, - `added` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `objects` int(10) unsigned NOT NULL, - `lastCheck` timestamp NULL DEFAULT NULL, - `tries` smallint(5) unsigned NOT NULL DEFAULT '0', - `started` timestamp NULL DEFAULT NULL, - `syncDownloadProcessID` int(10) unsigned DEFAULT NULL, - `finished` timestamp NULL DEFAULT NULL, - `finishedMS` smallint(5) unsigned NOT NULL DEFAULT '0', - `xmldata` text, - `errorCode` int(10) unsigned DEFAULT NULL, - `errorMessage` text, - PRIMARY KEY (`syncDownloadQueueID`), - KEY `userID` (`userID`), - KEY `sessionID` (`sessionID`), - KEY `started` (`started`), - KEY `syncDownloadProcessID` (`syncDownloadProcessID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `syncProcesses` ( - `syncProcessID` int(10) unsigned NOT NULL, - `userID` int(10) unsigned NOT NULL, - `started` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`syncProcessID`), - UNIQUE KEY `userID` (`userID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `syncProcessLocks` ( - `syncProcessID` int(10) unsigned NOT NULL, - `libraryID` int(10) unsigned NOT NULL, - PRIMARY KEY (`syncProcessID`,`libraryID`), - KEY `libraryID` (`libraryID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `syncUploadQueue` ( - `syncUploadQueueID` int(10) unsigned NOT NULL, - `processorHost` int(10) unsigned DEFAULT NULL, - `xmldata` text NOT NULL, - `dataLength` int(10) unsigned NOT NULL DEFAULT '0', - `hasCreator` tinyint(3) unsigned NOT NULL DEFAULT '0', - `userID` int(10) unsigned NOT NULL, - `sessionID` char(32) CHARACTER SET ascii NOT NULL, - `added` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `errorCheck` tinyint(1) unsigned NOT NULL DEFAULT '0', - `tries` smallint(5) unsigned NOT NULL DEFAULT '0', - `started` timestamp NULL DEFAULT NULL, - `startedMS` smallint(5) unsigned NOT NULL DEFAULT '0', - `syncProcessID` int(10) unsigned DEFAULT NULL, - `finished` timestamp NULL DEFAULT NULL, - `finishedMS` smallint(5) unsigned NOT NULL DEFAULT '0', - `errorCode` int(10) unsigned DEFAULT NULL, - `errorMessage` mediumtext, - PRIMARY KEY (`syncUploadQueueID`), - UNIQUE KEY `sessionID` (`sessionID`), - UNIQUE KEY `syncProcessID` (`syncProcessID`), - KEY `userID` (`userID`), - KEY `started` (`started`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `syncUploadQueueLocks` ( - `syncUploadQueueID` int(10) unsigned NOT NULL, - `libraryID` int(10) unsigned NOT NULL, - PRIMARY KEY (`syncUploadQueueID`,`libraryID`), - KEY `libraryID` (`libraryID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `syncUploadProcessLog` ( - `userID` int(10) unsigned NOT NULL, - `dataLength` int(10) unsigned NOT NULL, - `processorHost` int(10) unsigned NOT NULL, - `processDuration` float(6,2) NOT NULL, - `totalDuration` smallint(5) unsigned NOT NULL, - `error` tinyint(4) NOT NULL DEFAULT '0', - `finished` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - KEY `finished` (`finished`), - KEY `userID` (`userID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `syncUploadQueuePostWriteLog` ( - `syncUploadQueueID` int(10) unsigned NOT NULL, - `objectType` enum('group','groupUser') NOT NULL, - `ids` varchar(30) NOT NULL, - `action` enum('update','delete') NOT NULL, - PRIMARY KEY (`syncUploadQueueID`,`objectType`,`ids`,`action`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `users` ( - `userID` int(10) unsigned NOT NULL, - `libraryID` int(10) unsigned NOT NULL, - `username` varchar(255) NOT NULL, - `joined` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `lastSyncTime` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', - PRIMARY KEY (`userID`), - UNIQUE KEY `libraryID` (`libraryID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -CREATE TABLE `userPublications` ( - `userID` int(10) unsigned NOT NULL, - `libraryID` int(10) unsigned NOT NULL, - PRIMARY KEY (`userID`), - UNIQUE KEY `libraryID` (`libraryID`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - - - -ALTER TABLE `baseFieldMappings` - ADD CONSTRAINT `baseFieldMappings_ibfk_1` FOREIGN KEY (`itemTypeID`) REFERENCES `itemTypes` (`itemTypeID`), - ADD CONSTRAINT `baseFieldMappings_ibfk_2` FOREIGN KEY (`baseFieldID`) REFERENCES `fields` (`fieldID`), - ADD CONSTRAINT `baseFieldMappings_ibfk_3` FOREIGN KEY (`fieldID`) REFERENCES `fields` (`fieldID`); - -ALTER TABLE `groups` - ADD CONSTRAINT `groups_ibfk_1` FOREIGN KEY (`libraryID`) REFERENCES `libraries` (`libraryID`) ON DELETE CASCADE; - -ALTER TABLE `groupUsers` - ADD CONSTRAINT `groupUsers_ibfk_1` FOREIGN KEY (`groupID`) REFERENCES `groups` (`groupID`) ON DELETE CASCADE, - ADD CONSTRAINT `groupUsers_ibfk_2` FOREIGN KEY (`userID`) REFERENCES `users` (`userID`) ON DELETE CASCADE, - ADD CONSTRAINT `groupUsers_ibfk_3` FOREIGN KEY (`groupID`) REFERENCES `groups` (`groupID`) ON DELETE CASCADE, - ADD CONSTRAINT `groupUsers_ibfk_4` FOREIGN KEY (`userID`) REFERENCES `users` (`userID`) ON DELETE CASCADE; - -ALTER TABLE `itemTypeCreatorTypes` - ADD CONSTRAINT `itemTypeCreatorTypes_ibfk_1` FOREIGN KEY (`itemTypeID`) REFERENCES `itemTypes` (`itemTypeID`), - ADD CONSTRAINT `itemTypeCreatorTypes_ibfk_2` FOREIGN KEY (`creatorTypeID`) REFERENCES `creatorTypes` (`creatorTypeID`); - -ALTER TABLE `itemTypeFields` - ADD CONSTRAINT `itemTypeFields_ibfk_1` FOREIGN KEY (`itemTypeID`) REFERENCES `itemTypes` (`itemTypeID`), - ADD CONSTRAINT `itemTypeFields_ibfk_2` FOREIGN KEY (`fieldID`) REFERENCES `fields` (`fieldID`); - -ALTER TABLE `keys` - ADD CONSTRAINT `keys_ibfk_1` FOREIGN KEY (`userID`) REFERENCES `users` (`userID`) ON DELETE CASCADE; - -ALTER TABLE `keyPermissions` - ADD CONSTRAINT `keyPermissions_ibfk_1` FOREIGN KEY (`keyID`) REFERENCES `keys` (`keyID`) ON DELETE CASCADE; - -ALTER TABLE `libraries` - ADD CONSTRAINT `libraries_ibfk_1` FOREIGN KEY (`shardID`) REFERENCES `shards` (`shardID`); - -ALTER TABLE `sessions` - ADD CONSTRAINT `sessions_ibfk_1` FOREIGN KEY (`userID`) REFERENCES `users` (`userID`) ON DELETE CASCADE; - -ALTER TABLE `shardHostReplicas` - ADD CONSTRAINT `shardHostReplicas_ibfk_1` FOREIGN KEY (`shardHostID`) REFERENCES `shardHosts` (`shardHostID`) ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE `shards` - ADD CONSTRAINT `shards_ibfk_1` FOREIGN KEY (`shardHostID`) REFERENCES `shardHosts` (`shardHostID`); - -ALTER TABLE `storageAccounts` - ADD CONSTRAINT `storageAccounts_ibfk_1` FOREIGN KEY (`userID`) REFERENCES `users` (`userID`) ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE `storageFileLibraries` - ADD CONSTRAINT `storageFileLibraries_ibfk_1` FOREIGN KEY (`storageFileID`) REFERENCES `storageFiles` (`storageFileID`), - ADD CONSTRAINT `storageFileLibraries_ibfk_2` FOREIGN KEY (`libraryID`) REFERENCES `libraries` (`libraryID`) ON DELETE CASCADE; - -ALTER TABLE `storageLastSync` - ADD CONSTRAINT `storageLastSync_ibfk_1` FOREIGN KEY (`userID`) REFERENCES `users` (`userID`) ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE `storageUploadQueue` - ADD CONSTRAINT `storageUploadQueue_ibfk_1` FOREIGN KEY (`userID`) REFERENCES `users` (`userID`) ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE `syncDownloadQueue` - ADD CONSTRAINT `syncDownloadQueue_ibfk_1` FOREIGN KEY (`userID`) REFERENCES `users` (`userID`) ON DELETE CASCADE ON UPDATE CASCADE, - ADD CONSTRAINT `syncDownloadQueue_ibfk_3` FOREIGN KEY (`sessionID`) REFERENCES `sessions` (`sessionID`) ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE `syncProcesses` - ADD CONSTRAINT `syncProcesses_ibfk_1` FOREIGN KEY (`userID`) REFERENCES `users` (`userID`); - -ALTER TABLE `syncProcessLocks` - ADD CONSTRAINT `syncProcessLocks_ibfk_1` FOREIGN KEY (`syncProcessID`) REFERENCES `syncProcesses` (`syncProcessID`) ON DELETE CASCADE ON UPDATE CASCADE, - ADD CONSTRAINT `syncProcessLocks_ibfk_2` FOREIGN KEY (`libraryID`) REFERENCES `libraries` (`libraryID`) ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE `syncUploadQueue` - ADD CONSTRAINT `syncUploadQueue_ibfk_1` FOREIGN KEY (`userID`) REFERENCES `users` (`userID`) ON DELETE CASCADE ON UPDATE CASCADE, - ADD CONSTRAINT `syncUploadQueue_ibfk_2` FOREIGN KEY (`syncProcessID`) REFERENCES `syncProcesses` (`syncProcessID`) ON DELETE SET NULL, - ADD CONSTRAINT `syncUploadQueue_ibfk_3` FOREIGN KEY (`sessionID`) REFERENCES `sessions` (`sessionID`) ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE `syncUploadQueueLocks` - ADD CONSTRAINT `syncUploadQueueLocks_ibfk_1` FOREIGN KEY (`syncUploadQueueID`) REFERENCES `syncUploadQueue` (`syncUploadQueueID`) ON DELETE CASCADE ON UPDATE CASCADE, - ADD CONSTRAINT `syncUploadQueueLocks_ibfk_2` FOREIGN KEY (`libraryID`) REFERENCES `libraries` (`libraryID`) ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE `syncUploadQueuePostWriteLog` - ADD CONSTRAINT `syncUploadQueuePostWriteLog_ibfk_1` FOREIGN KEY (`syncUploadQueueID`) REFERENCES `syncUploadQueue` (`syncUploadQueueID`) ON DELETE CASCADE; - -ALTER TABLE `userPublications` - ADD CONSTRAINT `userPublications_ibfk_1` FOREIGN KEY (`userID`) REFERENCES `users` (`userID`) ON DELETE CASCADE, - ADD CONSTRAINT `userPublications_ibfk_2` FOREIGN KEY (`libraryID`) REFERENCES `libraries` (`libraryID`) ON DELETE CASCADE; - -ALTER TABLE `users` - ADD CONSTRAINT `users_ibfk_1` FOREIGN KEY (`libraryID`) REFERENCES `libraries` (`libraryID`) ON DELETE CASCADE; - - - -CREATE EVENT sessionGC ON SCHEDULE EVERY 5 MINUTE DO - DELETE S FROM sessions S LEFT JOIN syncUploadQueue USING (sessionID) WHERE timestamp3"); -shell_exec("echo 'DELETE FROM zotero_www_test.users WHERE userID>2' | mysql -h 127.0.0.1 -P 3311 -u root"); - -shell_exec("killall memcached; /usr/local/bin/memcached -d"); - -$shards = array(1,2); -foreach ($shards as $shardID) { - Zotero_DB::query("DELETE FROM shardLibraries WHERE libraryID>3", false, $shardID); -} - -// Sample data for user 1 -$itemObj = new Zotero_Item(); -$itemObj->setField('libraryID', 1); -$itemObj->setField('key', "AAAA2222", false, true); -$itemObj->setField('itemTypeID', 2, false, true); -$itemObj->save(); - -// Sample data for user 2 -$itemObj = new Zotero_Item(); -$itemObj->setField('libraryID', 2); -$itemObj->setField('key', "BBBB2222", false, true); -$itemObj->setField('itemTypeID', 2, false, true); -$itemObj->save(); - -// Sample attachment for user 2 -$itemObj = new Zotero_Item(); -$itemObj->setField('libraryID', 2); -$itemObj->setField('key', "CCCC4444", false, true); -$itemObj->setField('itemTypeID', 14, false, true); -$itemID = $itemObj->save(); - -// Sample data for group 1 -$itemObj = new Zotero_Item(); -$itemObj->setField('libraryID', 3); -$itemObj->setField('key', "CCCC2222", false, true); -$itemObj->setField('itemTypeID', 2, false, true); -$itemObj->save(); - -// Sample attachment for group 1 -$itemObj = new Zotero_Item(); -$itemObj->setField('libraryID', 3); -$itemObj->setField('key', "CCCC3333", false, true); -$itemObj->setField('itemTypeID', 14, false, true); -$itemID = $itemObj->save(); - -?> diff --git a/misc/triggers.sql b/misc/triggers.sql deleted file mode 100644 index 3f0437a7..00000000 --- a/misc/triggers.sql +++ /dev/null @@ -1,213 +0,0 @@ --- ***** BEGIN LICENSE BLOCK ***** --- --- This file is part of the Zotero Data Server. --- --- Copyright © 2010 Center for History and New Media --- George Mason University, Fairfax, Virginia, USA --- http://zotero.org --- --- This program is free software: you can redistribute it and/or modify --- it under the terms of the GNU Affero General Public License as published by --- the Free Software Foundation, either version 3 of the License, or --- (at your option) any later version. --- --- This program is distributed in the hope that it will be useful, --- but WITHOUT ANY WARRANTY; without even the implied warranty of --- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the --- GNU Affero General Public License for more details. --- --- You should have received a copy of the GNU Affero General Public License --- along with this program. If not, see . --- --- ***** END LICENSE BLOCK ***** - -delimiter // - -DROP TRIGGER IF EXISTS fki_collectionItems;// -CREATE TRIGGER fki_collectionItems - BEFORE INSERT ON collectionItems - FOR EACH ROW BEGIN - -- collectionItems libraryID - IF (SELECT libraryID FROM collections WHERE collectionID = NEW.collectionID) != (SELECT libraryID FROM items WHERE itemID = NEW.itemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM collectionItems; - END IF; - - -- Child items can't be in collections - IF ( - SELECT COUNT(*) FROM itemAttachments WHERE itemID=NEW.itemID AND sourceItemID IS NOT NULL - )=1 OR ( - SELECT COUNT(*) FROM itemNotes WHERE itemID=NEW.itemID AND sourceItemID IS NOT NULL - )=1 THEN - SELECT collection_item_must_be_top_level INTO @failure FROM collectionItems; - END IF; - END;// - - -DROP TRIGGER IF EXISTS fku_collectionItems;// -CREATE TRIGGER fku_collectionItems - BEFORE UPDATE ON collectionItems - FOR EACH ROW BEGIN - IF (SELECT libraryID FROM collections WHERE collectionID = NEW.collectionID) != (SELECT libraryID FROM items WHERE itemID = NEW.itemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM collectionItems; - END IF; - - IF ( - SELECT COUNT(*) FROM itemAttachments WHERE itemID=NEW.itemID AND sourceItemID IS NOT NULL - )=1 OR ( - SELECT COUNT(*) FROM itemNotes WHERE itemID=NEW.itemID AND sourceItemID IS NOT NULL - )=1 THEN - SELECT collection_item_must_be_top_level INTO @failure FROM collectionItems; - END IF; - END;// - -DROP TRIGGER IF EXISTS fki_itemAttachments;// -CREATE TRIGGER fki_itemAttachments - BEFORE INSERT ON itemAttachments - FOR EACH ROW BEGIN - -- itemAttachments libraryID - IF NEW.sourceItemID IS NOT NULL AND (SELECT libraryID FROM items WHERE itemID = NEW.itemID) != (SELECT libraryID FROM items WHERE itemID = NEW.sourceItemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM itemAttachments; - END IF; - - -- Make sure this is an attachment item - IF ((SELECT itemTypeID FROM items WHERE itemID = NEW.itemID) != 14) THEN - SELECT not_an_attachment INTO @failure FROM items; - END IF; - - -- Make sure parent is a regular item - IF (NEW.sourceItemID IS NOT NULL AND (SELECT itemTypeID FROM items WHERE itemID = NEW.sourceItemID) IN (1,14)) THEN - SELECT parent_not_regular_item INTO @failure FROM items; - END IF; - - -- If child, make sure attachment is not in a collection - IF (NEW.sourceItemID IS NOT NULL AND (SELECT COUNT(*) FROM collectionItems WHERE itemID=NEW.itemID)>0) THEN - SELECT collection_item_must_be_top_level INTO @failure FROM collectionItems; - END IF; - END;// - -DROP TRIGGER IF EXISTS fku_itemAttachments_libraryID;// -CREATE TRIGGER fku_itemAttachments_libraryID - BEFORE UPDATE ON itemAttachments - FOR EACH ROW BEGIN - IF NEW.sourceItemID IS NOT NULL AND (SELECT libraryID FROM items WHERE itemID = NEW.itemID) != (SELECT libraryID FROM items WHERE itemID = NEW.sourceItemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM itemAttachments; - END IF; - - -- Make sure parent is a regular item - IF (NEW.sourceItemID IS NOT NULL AND (SELECT itemTypeID FROM items WHERE itemID = NEW.sourceItemID) IN (1,14)) THEN - SELECT parent_not_regular_item INTO @failure FROM items; - END IF; - - -- If child, make sure attachment is not in a collection - IF (NEW.sourceItemID IS NOT NULL AND (SELECT COUNT(*) FROM collectionItems WHERE itemID=NEW.itemID)>0) THEN - SELECT collection_item_must_be_top_level INTO @failure FROM collectionItems; - END IF; - END;// - - --- itemCreators libraryID -DROP TRIGGER IF EXISTS fki_itemCreators_libraryID;// -CREATE TRIGGER fki_itemCreators_libraryID - BEFORE INSERT ON itemCreators - FOR EACH ROW BEGIN - IF (SELECT libraryID FROM creators WHERE creatorID = NEW.creatorID) != (SELECT libraryID FROM items WHERE itemID = NEW.itemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM itemCreators; - END IF; - END;// - -DROP TRIGGER IF EXISTS fku_itemCreators_libraryID;// -CREATE TRIGGER fku_itemCreators_libraryID - BEFORE UPDATE ON itemCreators - FOR EACH ROW BEGIN - IF (SELECT libraryID FROM creators WHERE creatorID = NEW.creatorID) != (SELECT libraryID FROM items WHERE itemID = NEW.itemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM itemCreators; - END IF; - END;// - - -DROP TRIGGER IF EXISTS fki_itemNotes;// -CREATE TRIGGER fki_itemNotes - BEFORE INSERT ON itemNotes - FOR EACH ROW BEGIN - -- itemNotes libraryID - IF NEW.sourceItemID IS NOT NULL AND (SELECT libraryID FROM items WHERE itemID = NEW.itemID) != (SELECT libraryID FROM items WHERE itemID = NEW.sourceItemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM itemNotes; - END IF; - - -- Make sure this is an attachment or note item - IF ((SELECT itemTypeID FROM items WHERE itemID = NEW.itemID) NOT IN (1,14)) THEN - SELECT not_an_attachment INTO @failure FROM items; - END IF; - - -- Make sure parent is a regular item - IF (NEW.sourceItemID IS NOT NULL AND (SELECT itemTypeID FROM items WHERE itemID = NEW.sourceItemID) IN (1,14)) THEN - SELECT parent_not_regular_item INTO @failure FROM items; - END IF; - - -- If child, make sure note is not in a collection - IF (NEW.sourceItemID IS NOT NULL AND (SELECT COUNT(*) FROM collectionItems WHERE itemID=NEW.itemID)>0) THEN - SELECT collection_item_must_be_top_level INTO @failure FROM collectionItems; - END IF; - END;// - -DROP TRIGGER IF EXISTS fku_itemNotes_libraryID;// -CREATE TRIGGER fku_itemNotes_libraryID - BEFORE UPDATE ON itemNotes - FOR EACH ROW BEGIN - IF NEW.sourceItemID IS NOT NULL AND (SELECT libraryID FROM items WHERE itemID = NEW.itemID) != (SELECT libraryID FROM items WHERE itemID = NEW.sourceItemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM itemNotes; - END IF; - - -- Make sure parent is a regular item - IF (NEW.sourceItemID IS NOT NULL AND (SELECT itemTypeID FROM items WHERE itemID = NEW.sourceItemID) IN (1,14)) THEN - SELECT parent_not_regular_item INTO @failure FROM items; - END IF; - - -- If child, make sure note is not in a collection - IF (NEW.sourceItemID IS NOT NULL AND (SELECT COUNT(*) FROM collectionItems WHERE itemID=NEW.itemID)>0) THEN - SELECT collection_item_must_be_top_level INTO @failure FROM collectionItems; - END IF; - END;// - - --- itemRelated libraryID -DROP TRIGGER IF EXISTS fki_itemRelated_libraryID;// -CREATE TRIGGER fki_itemRelated_libraryID - BEFORE INSERT ON itemRelated - FOR EACH ROW BEGIN - IF (SELECT libraryID FROM items WHERE itemID = NEW.itemID) != (SELECT libraryID FROM items WHERE itemID = NEW.linkedItemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM itemRelated; - END IF; - END;// - -DROP TRIGGER IF EXISTS fku_itemRelated_libraryID;// -CREATE TRIGGER fku_itemRelated_libraryID - BEFORE UPDATE ON itemRelated - FOR EACH ROW BEGIN - IF (SELECT libraryID FROM items WHERE itemID = NEW.itemID) != (SELECT libraryID FROM items WHERE itemID = NEW.linkedItemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM itemRelated; - END IF; - END;// - - --- itemTags libraryID -DROP TRIGGER IF EXISTS fki_itemTags_libraryID;// -CREATE TRIGGER fki_itemTags_libraryID - BEFORE INSERT ON itemTags - FOR EACH ROW BEGIN - IF (SELECT libraryID FROM tags WHERE tagID = NEW.tagID) != (SELECT libraryID FROM items WHERE itemID = NEW.itemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM itemTags; - END IF; - END;// - -DROP TRIGGER IF EXISTS fku_itemTags_libraryID;// -CREATE TRIGGER fku_itemTags_libraryID - BEFORE UPDATE ON itemTags - FOR EACH ROW BEGIN - IF (SELECT libraryID FROM tags WHERE tagID = NEW.tagID) != (SELECT libraryID FROM items WHERE itemID = NEW.itemID) THEN - SELECT libraryIDs_do_not_match INTO @failure FROM itemTags; - END IF; - END;// - - -delimiter ; diff --git a/model/API.inc.php b/model/API.inc.php deleted file mode 100644 index b7613d05..00000000 --- a/model/API.inc.php +++ /dev/null @@ -1,1330 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_API { - const MAX_BIBLIOGRAPHY_ITEMS = 150; - const MAX_OBJECT_KEYS = 100; - - public static $maxWriteCollections = 50; - public static $maxWriteItems = 50; - public static $maxWriteSearches = 50; - public static $maxWriteSettings = 50; - public static $maxWriteFullText = 10; - public static $maxTranslateItems = 10; - - private static $validAPIVersions = [1, 2, 3]; - - - private static $defaultParams = [ - 'v' => 3, - - 'format' => [ - 'v' => [ - 'default' => [ - 'action' => [ - 'default' => 'json', - 'fulltext' => 'versions' - ] - ], - 1 => [ - 'action' => [ - 'default' => 'atom', - 'fulltext' => 'versions', - 'itemContent' => 'json' - ] - ], - 2 => [ - 'action' => [ - 'default' => 'atom', - 'fulltext' => 'versions', - 'itemContent' => 'json', - 'deleted' => 'json', - 'settings' => 'json' - ] - ] - ] - ], - - 'include' => ['data'], - 'content' => ['html'], - - // format='bib' - 'style' => "chicago-note-bibliography", - 'css' => "inline", - 'linkwrap' => 0, - - // search - 'fq' => '', - 'q' => '', - 'qmode' => 'titleCreatorYear', - 'includeTrashed' => 0, - 'itemType' => '', - 'itemKey' => [], - 'collectionKey' => [], - 'searchKey' => [], - 'tag' => '', - 'tagType' => '', - 'since' => null, - 'sincetime' => null, - - 'sort' => [ - 'v' => [ - 'default' => [ - 'format' => [ - 'default' => 'dateModified', - 'atom' => 'dateAdded' - ] - ], - 1 => 'dateAdded', - 2 => 'dateAdded' - ] - ], - 'direction' => 'desc', - 'start' => 0, - 'limit' => [ - 'v' => [ - 'default' => [ - 'format' => [ - 'default' => 25, - 'bib' => self::MAX_BIBLIOGRAPHY_ITEMS, - 'keys' => 0, - 'versions' => 0 - ] - ], - 1 => [ - 'format' => [ - 'default' => 50, - 'bib' => self::MAX_BIBLIOGRAPHY_ITEMS, - 'keys' => 0, - 'versions' => 0 - ] - ], - 2 => [ - 'format' => [ - 'default' => 50, - 'bib' => self::MAX_BIBLIOGRAPHY_ITEMS, - 'keys' => 0, - 'versions' => 0 - ] - ] - ] - ], - - // For internal use only - 'emptyFirst' => false, - 'uncached' => false, - 'publications' => false - ]; - - - /** - * Parse query string into parameters, validating and filling in defaults - */ - public static function parseQueryParams($queryString, $action, $singleObject, $apiVersion=false, $atomAccepted=false) { - // Handle multiple identical parameters in the CGI-standard way instead of - // PHP's foo[]=bar way - $queryParams = Zotero_URL::proper_parse_str($queryString); - $finalParams = []; - - // - // Handle some special cases - // - // If client accepts Atom, serve it if an explicit format isn't requested - if ($atomAccepted && empty($queryParams['format'])) { - $queryParams['format'] = 'atom'; - } - - // Set API version based on header - if ($apiVersion) { - if (!empty($queryParams['v']) && $apiVersion != $queryParams['v']) { - throw new Exception("Zotero-API-Version header does not match 'v' query parameter", Z_ERROR_INVALID_INPUT); - } - $queryParams['v'] = $apiVersion; - } - // v1 documentation specifies 'version' query parameter - else if (isset($queryParams['version']) && $queryParams['version'] == 1 && !isset($queryParams['v'])) { - $queryParams['v'] = 1; - unset($queryParams['version']); - } - - // If format=json, override version to 3 - if (!isset($queryParams['v']) && isset($queryParams['format']) && $queryParams['format'] == 'json') { - $queryParams['v'] = 3; - } - - $apiVersion = isset($queryParams['v']) ? $queryParams['v'] : self::$defaultParams['v']; - - // If 'content', override 'format' to 'atom' - if (!isset($queryParams['format']) && isset($queryParams['content'])) { - $queryParams['format'] = 'atom'; - } - - // Handle deprecated (in v3) 'order' parameter - if (isset($queryParams['order'])) { - // If 'order' is a direction, move it to 'direction' - if (in_array($queryParams['order'], ['asc', 'desc'])) { - $finalParams['direction'] = $queryParams['direction'] = $queryParams['order']; - } - // Otherwise it's a field, so move it to 'sort' - else { - // If 'sort' already has a direction, move that to 'direction' first - if (isset($queryParams['sort']) && in_array($queryParams['sort'], ['asc', 'desc'])) { - $finalParams['direction'] = $queryParams['direction'] = $queryParams['sort']; - } - - $queryParams['sort'] = $queryParams['order']; - } - unset($queryParams['order']); - } - - // Handle deprecated (in v3) 'newer' and 'newertime' parameters - if (isset($queryParams['newer'])) { - if (!isset($queryParams['since'])) { - $queryParams['since'] = $queryParams['newer']; - } - unset($queryParams['newer']); - } - if (isset($queryParams['newertime'])) { - if (!isset($queryParams['sincetime'])) { - $queryParams['sincetime'] = $queryParams['newertime']; - } - unset($queryParams['newertime']); - } - - foreach (self::resolveDefaultParams($action, self::$defaultParams, $queryParams) as $key => $value) { - // Don't overwrite field if already set (either above or derived from another field) - if (!empty($finalParams[$key])) { - continue; - } - - // Fill defaults - $finalParams[$key] = $value; - - // Ignore private parameters in the URL - if (in_array($key, self::getPrivateParams($key)) && isset($queryParams[$key])) { - continue; - } - - // If no parameter passed, use default - if (!isset($queryParams[$key])) { - continue; - } - - // Use query parameter as value - $value = $queryParams[$key]; - - if (isset($finalParams['format'])) { - $format = $finalParams['format']; - - // Some formats need special parameter handling - if ($format == 'bib') { - switch ($key) { - // Invalid parameters - case 'order': - case 'sort': - case 'start': - case 'limit': - case 'direction': - throw new Exception("'$key' is not valid for format=bib", Z_ERROR_INVALID_INPUT); - } - } - else if ($apiVersion < 3 && in_array($format, array('keys', 'versions'))) { - switch ($key) { - // Invalid parameters - case 'start': - throw new Exception("'$key' is not valid for format=$format", Z_ERROR_INVALID_INPUT); - } - } - } - - switch ($key) { - case 'v': - if (!in_array($value, self::$validAPIVersions)) { - throw new Exception("Invalid API version '$value'", Z_ERROR_INVALID_INPUT); - } - break; - - case 'format': - if (!self::isValidFormatForAction($action, $value, $singleObject)) { - throw new Exception("Invalid 'format' value '$value'", Z_ERROR_INVALID_INPUT); - } - break; - - case 'since': - case 'sincetime': - if (!is_numeric($value)) { - throw new Exception("Invalid value for '$key' parameter", Z_ERROR_INVALID_INPUT); - } - break; - - case 'start': - $value = (int) $value; - break; - - case 'limit': - // Maximum limit depends on 'format' - $limitMax = self::getLimitMax($format); - - // Since the export formats and csljson don't give a clear indication of limiting or - // rel="next" links in API v1/2 (before the Link header), require an explicit limit for - // everything other than single items and itemKey queries - if ($apiVersion < 3 - && (in_array($format, Zotero_Translate::$exportFormats) || $format == 'csljson') - && !$singleObject - && empty($queryParams['itemKey'])) { - if (empty($value)) { - throw new Exception("'limit' is required for format=$format", Z_ERROR_INVALID_INPUT); - } - // Also enforce maximum limit - // TODO: Do this for all formats? - else if ($value > $limitMax) { - throw new Exception("'limit' cannot be greater than $limitMax for format=$format", Z_ERROR_INVALID_INPUT); - } - } - - // If there's a maximum, enforce it - if ($limitMax && (int) $value > $limitMax) { - $value = $limitMax; - } - // Use default if less than 0 or invalid - else if ((int) $value <= 0) { - continue 2; - } - $value = (int) $value; - break; - - case 'include': - case 'content': - if ($key == 'content' && $format != 'atom') { - throw new Exception("'content' is valid only for format=atom", Z_ERROR_INVALID_INPUT); - } - else if ($key == 'include' && $format != 'json') { - throw new Exception("'include' is valid only for format=json", Z_ERROR_INVALID_INPUT); - } - $value = array_values(array_unique(explode(',', $value))); - sort($value); - foreach ($value as $includeType) { - switch ($includeType) { - case 'none': - if (sizeOf($value) > 1) { - throw new Exception( - "$key=$includeType is not valid in multi-format responses", - Z_ERROR_INVALID_INPUT - ); - } - break; - - case 'html': - case 'citation': - case 'bib': - case 'csljson': - break; - - case 'json': - if ($format != 'atom') { - throw new Exception("$key=$includeType is valid only for format=atom", Z_ERROR_INVALID_INPUT); - } - break; - - case 'data': - if ($format != 'json') { - throw new Exception("$key=$includeType is valid only for format=json", Z_ERROR_INVALID_INPUT); - } - break; - - default: - if (in_array($includeType, Zotero_Translate::$exportFormats)) { - break; - } - throw new Exception("Invalid '$key' value '$includeType'", Z_ERROR_INVALID_INPUT); - } - } - break; - - case 'sort': - // If direction, move to 'direction' and use default 'sort' value - if (in_array($value, array('asc', 'desc'))) { - $finalParams['direction'] = $queryParams['direction'] = $value; - continue 2; - } - - // Whether to sort empty values first - $finalParams['emptyFirst'] = self::getSortEmptyFirst($value); - - switch ($value) { - // Valid fields to sort by - // - // Allow all fields available in client - case 'title': - case 'creator': - case 'itemType': - case 'date': - case 'publisher': - case 'publicationTitle': - case 'journalAbbreviation': - case 'language': - case 'accessDate': - case 'libraryCatalog': - case 'callNumber': - case 'rights': - case 'dateAdded': - case 'dateModified': - //case 'numChildren': - - case 'addedBy': - case 'numItems': - case 'serverDateModified': - - case 'collectionKeyList': - case 'itemKeyList': - case 'searchKeyList': - - switch ($value) { - // numItems is valid only for tags requests - case 'numItems': - if ($action != 'tags') { - throw new Exception("Invalid 'order' value '$value'", Z_ERROR_INVALID_INPUT); - } - break; - - case 'collectionKeyList': - if ($action != 'collections') { - throw new Exception("order=collectionKeyList is not valid for this request"); - } - if (!isset($queryParams['collectionKey'])) { - throw new Exception("order=collectionKeyList requires the collectionKey parameter"); - } - break; - - case 'itemKeyList': - if ($action != 'items') { - throw new Exception("order=itemKeyList is not valid for this request"); - } - if (!isset($queryParams['itemKey'])) { - throw new Exception("order=itemKeyList requires the itemKey parameter"); - } - break; - - case 'searchKeyList': - if ($action != 'searches') { - throw new Exception("order=searchKeyList is not valid for this request"); - } - if (!isset($queryParams['searchKey'])) { - throw new Exception("order=searchKeyList requires the searchKey parameter"); - } - break; - } - - if (!isset($queryParams['direction'])) { - $finalParams['direction'] = self::getDefaultDirection($value); - } - break; - - default: - throw new Exception("Invalid 'sort' value '$value'", Z_ERROR_INVALID_INPUT); - } - break; - - case 'direction': - if (!in_array($value, array('asc', 'desc'))) { - throw new Exception("Invalid '$key' value '$value'", Z_ERROR_INVALID_INPUT); - } - break; - - case 'qmode': - if (!in_array($value, array('titleCreatorYear', 'everything'))) { - throw new Exception("Invalid '$key' value '$value'", Z_ERROR_INVALID_INPUT); - } - break; - - case 'collectionKey': - case 'itemKey': - case 'searchKey': - // Allow leading/trailing commas - $objectKeys = trim($value, ","); - $objectKeys = explode(",", $objectKeys); - // Make sure all keys are plausible - foreach ($objectKeys as $objectKey) { - if (!Zotero_ID::isValidKey($objectKey)) { - throw new Exception("Invalid '$key' value '$value'", Z_ERROR_INVALID_INPUT); - } - } - $value = $objectKeys; - - // Force limit if explicit object keys are used - $finalParams['limit'] = self::MAX_OBJECT_KEYS; - break; - - case 'includeTrashed': - case 'uncached': - $value = !!$value; - break; - } - - $finalParams[$key] = $value; - } - - return $finalParams; - } - - - /** - * Return the parameters not set to their default values - */ - public static function getNonDefaultParams($action, $params) { - $defaults = self::resolveDefaultParams($action, self::$defaultParams, $params); - - $nonDefaults = []; - foreach ($params as $key => $val) { - switch ($key) { - case 'direction': - if ($val == self::getDefaultDirection($params['sort'])) { - continue 2; - } - break; - } - - if (!isset($defaults[$key])) { - continue; - } - - // Convert default array values to strings - $default = is_array($defaults[$key]) ? implode(',', $defaults[$key]) : $defaults[$key]; - if ($defaults[$key] == $val) { - continue; - } - - $nonDefaults[$key] = $val; - } - return $nonDefaults; - } - - - /** - * Build query string from given parameters, removing private parameters, adjusting - * parameter names based on API version, and converting arrays to strings - */ - public static function buildQueryString($apiVersion, $action, $params, $excludeParams=[]) { - $params = self::removeParams($params, $excludeParams); - $params = self::removePrivateParams($params); - $tmpParams = []; - foreach ($params as $key => $val) { - if ($apiVersion < 3) { - if ($key == 'sort') { - $key = 'order'; - } - else if ($key == 'direction') { - $key = 'sort'; - } - } - $tmpParams[$key] = $val; - } - $params = $tmpParams; - - if (!$params) { - return ""; - } - // Sort parameters alphabetically - ksort($params); - foreach ($params as $key => $val) { - if (is_array($val)) { - $params[$key] = implode(',', $val); - } - } - return '?' . http_build_query($params); - } - - - /** - * Generate self/first/prev/next/last/alternate links - */ - public static function buildLinks($action, $path, $totalResults, $queryParams, $nonDefaultParams, $excludeParams=[]) { - $apiVersion = $queryParams['v']; - $baseURI = Zotero_API::getBaseURI() . substr($path, 1); - - $links = []; - - // - // Generate URIs for 'self', 'first', 'next' and 'last' links - // - // 'self' - $links['self'] = $baseURI; - if ($nonDefaultParams) { - $links['self'] .= Zotero_API::buildQueryString($apiVersion, $action, $nonDefaultParams, $excludeParams); - } - - // 'first' - $links['first'] = $baseURI; - if ($nonDefaultParams) { - $p = $nonDefaultParams; - unset($p['start']); - $links['first'] .= Zotero_API::buildQueryString($apiVersion, $action, $p, $excludeParams); - } - - // 'prev' - if ($queryParams['start']) { - $p = $nonDefaultParams; - $prevStart = $queryParams['start'] - $queryParams['limit']; - if ($prevStart <= 0) { - unset($p['start']); - } - else { - $p['start'] = $prevStart; - } - $links['prev'] = $baseURI . Zotero_API::buildQueryString($apiVersion, $action, $p, $excludeParams); - } - - // 'last' - if (!$queryParams['start'] && $queryParams['limit'] >= $totalResults) { - $links['last'] = $links['self']; - } - else if ($queryParams['limit'] != 0) { - // 'start' past results - if ($queryParams['start'] >= $totalResults) { - $lastStart = $totalResults - $queryParams['limit']; - } - else { - $lastStart = $totalResults - ($totalResults % $queryParams['limit']); - if ($lastStart == $totalResults) { - $lastStart = $totalResults - $queryParams['limit']; - } - } - $p = $nonDefaultParams; - if ($lastStart > 0) { - $p['start'] = $lastStart; - } - else { - unset($p['start']); - } - $links['last'] = $baseURI . Zotero_API::buildQueryString($apiVersion, $action, $p, $excludeParams); - - // 'next' - $nextStart = $queryParams['start'] + $queryParams['limit']; - if ($nextStart < $totalResults) { - $p = $nonDefaultParams; - $p['start'] = $nextStart; - $links['next'] = $baseURI . Zotero_API::buildQueryString($apiVersion, $action, $p, $excludeParams); - } - } - - $links['alternate'] = Zotero_URI::getBaseWWWURI() . substr($path, 1); - - return $links; - } - - - private static function isValidFormatForAction($action, $format, $singleObject=false) { - $isExportFormat = in_array($format, Zotero_Translate::$exportFormats); - - if ($action == 'items') { - if ($isExportFormat) { - return true; - } - - switch ($format) { - case 'atom': - case 'bib': - case 'globalKeys': - case 'json': - return true; - - case 'keys': - case 'versions': - if (!$singleObject) { - return true; - } - break; - } - return false; - } - else if ($action == 'collections' || $action == 'searches') { - switch ($format) { - case 'json': - case 'keys': - case 'versions': - if (!$singleObject) { - return true; - } - break; - } - } - else if ($action == 'fulltext') { - return $format == 'versions'; - } - else if ($action == 'groups') { - switch ($format) { - case 'atom': - case 'json': - case 'etags': - case 'versions': - return true; - } - return false; - } - - // Ignore format for other actions - return true; - } - - - private static function getDefaultDirection($field="") { - // Use descending for date fields - // TODO: use predefined field formats - if (strpos($field, 'date') === 0) { - return 'desc'; - } - - switch ($field) { - default: - return 'asc'; - } - } - - - public static function getLimitMax($format="") { - switch ($format) { - case 'keys': - case 'versions': - return 0; - - case 'bib': - return self::MAX_BIBLIOGRAPHY_ITEMS; - } - - return 100; - } - - - private static function getSortEmptyFirst($field) { - switch ($field) { - case 'title': - case 'date': - case 'collectionKeyList': - case 'itemKeyList': - case 'searchKeyList': - return true; - } - - return false; - } - - - private static function resolveDefaultParams($action, $defaultParams, $requestParams) { - $params = []; - foreach ($defaultParams as $key => $val) { - $params[$key] = self::resolveDefaultParam($key, $action, $defaultParams, $requestParams); - } - return $params; - } - - - /** - * Get the default value for a given parameter, which may be dependent on other parameters - * (either defaults or those set by the request) - */ - private static function resolveDefaultParam($param, $action, $defaultParams, $requestParams, $useRequestParams=false) { - if ($useRequestParams && !empty($requestParams[$param])) { - return $requestParams[$param]; - } - if ($param == 'action') { - return $action; - } - try { - return self::resolveDefaultParamRecursive($action, $defaultParams, $requestParams, $defaultParams[$param]); - } - catch (Exception $e) { - throw new Exception("Can't resolve default value for '$param' (" . $e->getMessage() . ")"); - } - } - - - private static function resolveDefaultParamRecursive($action, $defaultParams, $requestParams, $block) { - // If we've reached a regular value or array, just return it - if (is_scalar($block) || !$block || (is_array($block) && array_keys($block)[0] === 0)) { - return $block; - } - // Otherwise, get the dependency, which should be the sole property - if (sizeOf($block) != 1) { - throw new Exception("Invalid default parameter value: " . json_encode($block)); - } - $depKey = array_keys($block)[0]; - - // Get the value for the dependency (including in the request, since it's the - // active parameter that matters here) - $depVal = self::resolveDefaultParam($depKey, $action, $defaultParams, $requestParams, true); - - $resolved = false; - // And follow its dependency chain - if (isset($block[$depKey][$depVal])) { - $resolved = self::resolveDefaultParamRecursive($action, $defaultParams, $requestParams, $block[$depKey][$depVal]); - } - // If that wasn't fruitful, use 'default' - if ($resolved === false) { - if (!isset($block[$depKey]['default'])) { - throw new Exception("No default value"); - } - $resolved = self::resolveDefaultParamRecursive($action, $defaultParams, $requestParams, $block[$depKey]['default']); - if ($resolved === false) { - throw new Exception("Cannot resolve default value"); - } - } - return $resolved; - } - - - private static function removeParams($params, $excludeParams) { - $filtered = []; - foreach ($params as $key => $val) { - if (!in_array($key, $excludeParams)) { - $filtered[$key] = $val; - } - } - return $filtered; - } - - - private static function removePrivateParams($params) { - return self::removeParams($params, self::getPrivateParams()); - } - - - private static function getPrivateParams() { - $params = ['emptyFirst', 'publications']; - if (!Z_CONFIG::$TESTING_SITE) { - $params[] = 'uncached'; - } - return $params; - } - - - // - // URI generation - // - public static function getBaseURI() { - return Z_CONFIG::$API_BASE_URI; - } - - - public static function getLibraryURI($libraryID) { - $libraryType = Zotero_Libraries::getType($libraryID); - switch ($libraryType) { - case 'user': - $id = Zotero_Users::getUserIDFromLibraryID($libraryID); - return self::getBaseURI() . "users/$id"; - - case 'publications': - $id = Zotero_Users::getUserIDFromLibraryID($libraryID); - return self::getBaseURI() . "users/$id/publications"; - - case 'group': - $id = Zotero_Groups::getGroupIDFromLibraryID($libraryID); - return self::getBaseURI() . "groups/$id"; - - default: - throw new Exception("Invalid library type '$libraryType'"); - } - } - - - public static function getUserURI($userID) { - return self::getBaseURI() . "users/$userID"; - } - - - public static function getGroupURI(Zotero_Group $group) { - return self::getBaseURI() . "groups/$group->id"; - } - - - public static function getGroupUserURI(Zotero_Group $group, $userID) { - return self::getGroupURI($group) . "/users/$userID"; - } - - - public static function getObjectURI($obj) { - switch (get_class($obj)) { - case 'Zotero_Collection': - return self::getCollectionURI($obj); - - case 'Zotero_Item': - return self::getItemURI($obj); - - case 'Zotero_Search': - return self::getSearchURI($obj); - - default: - throw new Exception("Unexpected object type '" . get_class($obj) . "'"); - } - } - - - public static function getCollectionURI(Zotero_Collection $collection) { - return self::getLibraryURI($collection->libraryID) . "/collections/$collection->key"; - } - - - public static function getCollectionsURI($libraryID) { - return self::getLibraryURI($libraryID) . "/collections"; - } - - - public static function getCreatorURI(Zotero_Creator $creator) { - return self::getLibraryURI($creator->libraryID) . "/creators/$creator->key"; - } - - - public static function getItemURI(Zotero_Item $item) { - return self::getLibraryURI($item->libraryID) . "/items/$item->key"; - } - - - public static function getItemsURI($libraryID) { - return self::getLibraryURI($libraryID) . "/items"; - } - - - public static function getSearchURI(Zotero_Search $search) { - return self::getLibraryURI($search->libraryID) . "/searches/$search->key"; - } - - - public static function getTagURI(Zotero_Tag $tag) { - return self::getLibraryURI($tag->libraryID) . "/tags/" . urlencode($tag->name); - } - - - public static function getKeyURI(Zotero_Key $key) { - return self::getBaseURI() . $key->userID . "/keys/" . urlencode($key->key); - } - - public static function outputContentType($format) { - $contentType = self::getContentTypeForFormat($format); - if ($contentType !== false) { - header('Content-Type: ' . $contentType); - } - } - - public static function getContentTypeForFormat($format) { - switch ($format) { - case 'atom': - return 'application/atom+xml'; - - case 'bib': - return 'text/html; charset=UTF-8'; - - case 'csljson': - return 'application/vnd.citationstyles.csl+json'; - - case 'json': - return 'application/json'; - - case 'keys': - return 'text/plain'; - - case 'versions': - case 'writereport': - return 'application/json'; - - // Export formats -- normally we get these from translation-server, but we hard-code them - // here for HEAD requests, which don't run the translation. This should match - // SERVER_CONTENT_TYPES in src/server_translation.js in translation-server. - case 'bibtex': - case 'biblatex': - return 'application/x-bibtex'; - - case 'bookmarks': - case 'coins': - return 'text/html'; - - case 'mods': - return 'application/mods+xml'; - - case 'rdf_bibliontology': - case 'rdf_dc': - case 'rdf_zotero': - return 'application/rdf+xml'; - - case 'refer': - case 'ris': - return 'application/x-research-info-systems'; - - case 'tei': - return 'text/xml'; - - case 'wikipedia': - return 'text/x-wiki'; - } - - return false; - } - - - public static function buildLinkHeader($action, $url, $totalResults, array $queryParams) { - $path = parse_url($url, PHP_URL_PATH); - $nonDefaultParams = self::getNonDefaultParams($action, $queryParams); - $links = self::buildLinks($action, $path, $totalResults, $queryParams, $nonDefaultParams); - - $parts = []; - if (isset($links['first']) && $links['first'] != $links['self']) { - $parts[] = '<' . $links['first'] . '>; rel="first"'; - } - if (isset($links['prev'])) { - $parts[] = '<' . $links['prev'] . '>; rel="prev"'; - } - if (isset($links['next'])) { - $parts[] = '<' . $links['next'] . '>; rel="next"'; - } - if (isset($links['last']) && $links['last'] != $links['self']) { - $parts[] = '<' . $links['last'] . '>; rel="last"'; - } - $parts[] = '<' . $links['alternate'] . '>; rel="alternate"'; - - return $parts ? 'Link: ' . implode(', ', $parts) : false; - } - - - public static function multiResponse($options, $overrideFormat=false) { - $format = $overrideFormat ? $overrideFormat : $options['requestParams']['format']; - $isExportFormat = in_array($format, Zotero_Translate::$exportFormats); - - if (empty($options['results'])) { - $options['results'] = [ - 'results' => [], - 'total' => 0 - ]; - } - - if ($options['results'] && isset($options['results']['results'])) { - $totalResults = $options['results']['total']; - $options['results'] = $options['results']['results']; - if ($options['requestParams']['v'] >= 3) { - header("Total-Results: $totalResults"); - } - } - - if (in_array($format, ['atom', 'csljson', 'json', 'keys', 'versions']) || $isExportFormat) { - $link = Zotero_API::buildLinkHeader( - $options['action'], - $options['uri'], - $totalResults, - $options['requestParams'] - ); - if ($link) { - header($link); - } - } - - if (!empty($options['head'])) { - return; - } - - switch ($format) { - case 'atom': - $t = microtime(true); - $response = Zotero_Atom::createAtomFeed( - $options['action'], - $options['title'], - $options['uri'], - $options['results'], - $totalResults, - $options['requestParams'], - $options['permissions'], - isset($options['fixedValues']) ? $options['fixedValues'] : null - ); - StatsD::timing("api." . $options['action'] . ".multiple.createAtomFeed." - . implode("-", $options['requestParams']['content']), (microtime(true) - $t) * 1000); - return $response; - - case 'csljson': - $json = Zotero_Cite::getJSONFromItems($options['results'], true); - echo Zotero_Utilities::formatJSON($json); - break; - - case 'json': - echo Zotero_API::createJSONResponse($options['results'], $options['requestParams'], $options['permissions']); - break; - - case 'keys': - echo implode("\n", $options['results']) . "\n"; - break; - - case 'versions': - if (!empty($options['results'])) { - echo Zotero_Utilities::formatJSON($options['results']); - } - else { - echo Zotero_Utilities::formatJSON(new stdClass); - } - break; - - case 'writereport': - echo Zotero_Utilities::formatJSON($options['results']); - break; - - default: - if ($isExportFormat) { - $export = Zotero_Translate::doExport($options['results'], $options['requestParams']); - header("Content-Type: " . $export['mimeType']); - echo $export['body']; - } - else { - throw new Exception("Unexpected format '$format'"); - } - } - } - - - // - // JSON processing - // - public static function createJSONResponse($entries, array $queryParams, Zotero_Permissions $permissions=null) { - $json = []; - foreach ($entries as $entry) { - $json[] = $entry->toResponseJSON($queryParams, $permissions); - } - return Zotero_Utilities::formatJSON($json); - } - - - /** - * Trim full response JSON to editable JSON - */ - public static function extractEditableJSON($json) { - if (isset($json->data)) { - $json = $json->data; - } - return $json; - } - - - /** - * Validate the object key from JSON and load the passed object with it - * - * @param object $object Zotero_Item, Zotero_Collection, or Zotero_Search - * @param json $json - * @return boolean True if the object exists, false if not - */ - public static function processJSONObjectKey($object, $json, $requestParams) { - $objectType = \Zotero\DataObjectUtilities::getTypeFromObject($object); - if (!in_array($objectType, array('item', 'collection', 'search'))) { - throw new Exception("Invalid object type"); - } - - if ($requestParams['v'] >= 3) { - $keyProp = 'key'; - $versionProp = 'version'; - } - else { - $keyProp = $objectType . "Key"; - $versionProp = $objectType == 'setting' ? 'version' : $objectType . "Version"; - } - - // Validate the object key if present and determine if the object is new - if (isset($json->$keyProp)) { - if (!is_string($json->$keyProp)) { - throw new Exception( - "'$keyProp' must be a string", Z_ERROR_INVALID_INPUT - ); - } - if (!Zotero_ID::isValidKey($json->$keyProp)) { - throw new Exception("'" . $json->$keyProp . "' " - . "is not a valid $objectType key", Z_ERROR_INVALID_INPUT - ); - } - if ($object->key) { - if ($json->$keyProp != $object->key) { - throw new HTTPException("'$keyProp' property in JSON does not match " - . "$objectType key of request", 409); - } - - $exists = !!$object->id; - } - else { - $object->key = $json->$keyProp; - $exists = !!$object->id; - } - } - else { - $exists = !!$object->key; - } - - return $exists; - } - - - /** - * @param object $object Zotero object (Zotero_Item, Zotero_Collection, Zotero_Search, Zotero_Setting) - * @param object $json JSON object to check - * @param array $requestParams - * @param int $requireVersion If 0, don't require; if 1, require if there's - * an object key property in the JSON; if 2, - * always require - */ - public static function checkJSONObjectVersion($object, $json, $requestParams, $requireVersion) { - $objectType = \Zotero\DataObjectUtilities::getTypeFromObject($object); - if (!in_array($objectType, array('item', 'collection', 'search', 'setting'))) { - throw new Exception("Invalid object type"); - } - - $oldKeyProp = $objectType . "Key"; - $oldVersionProp = $objectType == 'setting' ? 'version' : $objectType . "Version"; - $newKeyProp = 'key'; - $newVersionProp = 'version'; - - if ($requestParams['v'] >= 3) { - $keyProp = $newKeyProp; - $versionProp = $newVersionProp; - - // Disallow old properties - if (isset($json->$oldKeyProp)) { - throw new Exception("'$oldKeyProp' property is now '" - . $newKeyProp. "'", Z_ERROR_INVALID_INPUT); - } - else if (isset($json->$oldVersionProp) && $oldVersionProp != $newVersionProp) { - throw new Exception("'$oldVersionProp' property is now '" - . $newVersionProp . "'", Z_ERROR_INVALID_INPUT); - } - } - else { - $keyProp = $oldKeyProp; - $versionProp = $oldVersionProp; - - // Disallow new properties - if (isset($json->$newKeyProp)) { - throw new Exception("Invalid property '$newKeyProp'", Z_ERROR_INVALID_INPUT); - } - else if (isset($json->$newVersionProp) && $oldVersionProp != $newVersionProp) { - throw new Exception("Invalid property '$newVersionProp'", Z_ERROR_INVALID_INPUT); - } - } - - if (isset($json->$versionProp)) { - if ($requestParams['v'] < 2) { - throw new Exception( - "Invalid property '$versionProp'", Z_ERROR_INVALID_INPUT - ); - } - if (!is_numeric($json->$versionProp)) { - throw new Exception( - "'$versionProp' must be an integer", Z_ERROR_INVALID_INPUT - ); - } - if (!isset($json->$keyProp) && $objectType != 'setting' && !$object->key) { - throw new Exception( - "'$versionProp' is valid only if $objectType key is provided", - Z_ERROR_INVALID_INPUT - ); - } - $originalVersion = Zotero_Libraries::getOriginalVersion($object->libraryID); - $updatedVersion = Zotero_Libraries::getUpdatedVersion($object->libraryID); - // Make sure the object hasn't been modified since the specified version - if ($object->version > $json->$versionProp) { - // Unless it was modified in this request - if ($updatedVersion != $originalVersion && $object->version == $updatedVersion) { - return; - } - throw new HTTPException(ucwords($objectType) - . " has been modified since specified version " - . "(expected {$json->$versionProp}, found {$object->version})" - , 412); - } - // If a version is specified, the object has to exist - else if ($json->$versionProp > 0 && !$object->version) { - throw new HTTPException(ucwords($objectType) - . " doesn't exist (expected version {$json->$versionProp}; use 0 instead)", 404); - } - } - else { - if ($requireVersion == 1 && isset($json->$keyProp)) { - if ($objectType == 'setting') { - throw new HTTPException( - "Either If-Unmodified-Since-Version or " - . "'$versionProp' property must be provided", 428 - ); - } - else { - throw new HTTPException( - "Either If-Unmodified-Since-Version or " - . "'$versionProp' property must be provided for " - . "'$keyProp'-based writes", 428 - ); - } - } - else if ($requireVersion == 2) { - throw new HTTPException( - "Either If-Unmodified-Since-Version or " - . "'$versionProp' property must be provided for " - . "single-$objectType writes", 428 - ); - } - } - } - - - /** - * Parse search parameters - */ - public static function getSearchParamValues($params, $param) { - if (!isset($params[$param])) { - return false; - } - - $vals = is_array($params[$param]) ? $params[$param] : array($params[$param]); - - $sets = array(); - foreach ($vals as $val) { - $val = trim($val); - if ($val === '') { - continue; - } - - $negation = false; - - // Negation - if ($val[0] == "-") { - $negation = true; - $val = substr($val, 1); - } - // Literal hyphen - else if (substr($val, 0, 2) == '\-') { - $val = substr($val, 1); - } - - // Separate into boolean OR parts - $parts = preg_split("/\s+\|\|\s+/", $val); - - $val = array( - 'negation' => $negation, - 'values' => $parts - ); - - $sets[] = $val; - } - - return $sets; - } -} -?> diff --git a/model/Atom.inc.php b/model/Atom.inc.php deleted file mode 100644 index c71d163c..00000000 --- a/model/Atom.inc.php +++ /dev/null @@ -1,197 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_Atom { - // Set up namespaces - public static $nsAtom = "http://www.w3.org/2005/Atom"; - public static $nsXHTML = "http://www.w3.org/1999/xhtml"; - public static $nsZoteroAPI = "http://zotero.org/ns/api"; - public static $nsZoteroTransfer = "http://zotero.org/ns/transfer"; - - - public static function createAtomFeed($action, $title, $url, $entries, $totalResults=null, - $queryParams=[], Zotero_Permissions $permissions=null, $fixedValues=array()) { - if ($queryParams) { - $nonDefaultParams = Zotero_API::getNonDefaultParams($action, $queryParams); - } - else { - $nonDefaultParams = []; - } - - $feed = '' - . ''; - $xml = new SimpleXMLElement($feed); - - $xml->title = $title; - - $path = parse_url($url, PHP_URL_PATH); - - $xml->id = Zotero_URI::getBaseURI() - . substr($path, 1) - . Zotero_API::buildQueryString( - $queryParams['v'], - $action, - $nonDefaultParams, - ['format', 'v'] - ); - - // API version isn't included in URLs (as with the API key) - // - // It could alternatively be made a private parameter so that it didn't appear - // in the Link header either, but for now it's still there. - $excludeParams = ['v']; - - $links = Zotero_API::buildLinks( - $action, - $path, - $totalResults, - $queryParams, - $nonDefaultParams, - $excludeParams - ); - - $link = $xml->addChild("link"); - $link['rel'] = "self"; - $link['type'] = "application/atom+xml"; - $link['href'] = $links['self']; - - $link = $xml->addChild("link"); - $link['rel'] = "first"; - $link['type'] = "application/atom+xml"; - $link['href'] = $links['first']; - - if (isset($links['next'])) { - $link = $xml->addChild("link"); - $link['rel'] = "next"; - $link['type'] = "application/atom+xml"; - $link['href'] = $links['next']; - } - - $link = $xml->addChild("link"); - $link['rel'] = "last"; - $link['type'] = "application/atom+xml"; - $link['href'] = $links['last']; - - // Generate alternate URI - $link = $xml->addChild("link"); - $link['rel'] = "alternate"; - $link['type'] = "text/html"; - $link['href'] = $links['alternate']; - - if ($queryParams['v'] < 3) { - $xml->addChild( - "zapi:totalResults", - is_numeric($totalResults) ? $totalResults : sizeOf($entries), - self::$nsZoteroAPI - ); - } - - if ($queryParams['v'] < 2) { - $xml->addChild("zapi:apiVersion", 1, self::$nsZoteroAPI); - } - - $latestUpdated = ''; - - // Check memcached for bib data - $sharedData = array(); - if ($entries && $entries[0] instanceof Zotero_Item) { - if (in_array('citation', $queryParams['content'])) { - $sharedData["citation"] = Zotero_Cite::multiGetFromMemcached("citation", $entries, $queryParams); - } - if (in_array('bib', $queryParams['content'])) { - $sharedData["bib"] = Zotero_Cite::multiGetFromMemcached("bib", $entries, $queryParams); - } - } - - $xmlEntries = array(); - foreach ($entries as $entry) { - if ($entry->dateModified > $latestUpdated) { - $latestUpdated = $entry->dateModified; - } - - if ($entry instanceof SimpleXMLElement) { - $xmlEntries[] = $entry; - } - else if ($entry instanceof Zotero_Collection) { - $entry = Zotero_Collections::convertCollectionToAtom($entry, $queryParams); - $xmlEntries[] = $entry; - } - else if ($entry instanceof Zotero_Item) { - $entry = Zotero_Items::convertItemToAtom($entry, $queryParams, $permissions, $sharedData); - $xmlEntries[] = $entry; - } - else if ($entry instanceof Zotero_Search) { - $entry = $entry->toAtom($queryParams); - $xmlEntries[] = $entry; - } - else if ($entry instanceof Zotero_Tag) { - $xmlEntries[] = $entry->toAtom( - $queryParams, - isset($fixedValues[$entry->id]) ? $fixedValues[$entry->id] : null - ); - } - else if ($entry instanceof Zotero_Group) { - $entry = $entry->toAtom($queryParams); - $xmlEntries[] = $entry; - } - } - - if ($latestUpdated) { - $xml->updated = Zotero_Date::sqlToISO8601($latestUpdated); - } - else { - $xml->updated = str_replace("+00:00", "Z", date('c')); - } - - // Import object XML nodes into document - $doc = dom_import_simplexml($xml); - foreach ($xmlEntries as $xmlEntry) { - $subNode = dom_import_simplexml($xmlEntry); - $importedNode = $doc->ownerDocument->importNode($subNode, true); - $doc->appendChild($importedNode); - } - - return $xml; - } - - - public static function addHTMLRow($html, $fieldName, $displayName, $value, $includeEmpty=false) { - if (!$includeEmpty && ($value === '' || $value === false)) { - return; - } - - $tr = $html->addChild('tr'); - if ($fieldName) { - $tr->addAttribute('class', $fieldName); - } - $th = $tr->addChild('th', $displayName); - $th['style'] = 'text-align: right'; - $td = $tr->addChild('td', htmlspecialchars($value)); - return $tr; - } -} -?> diff --git a/model/Attachments.inc.php b/model/Attachments.inc.php deleted file mode 100644 index 8e63cd2c..00000000 --- a/model/Attachments.inc.php +++ /dev/null @@ -1,302 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_Attachments { - private static $cacheTime = 60; // seconds to cache extracted ZIP files - - private static $linkModes = array( - 0 => "imported_file", - 1 => "imported_url", - 2 => "linked_file", - 3 => "linked_url" - ); - - public static function linkModeNumberToName($number) { - if (!isset(self::$linkModes[$number])) { - throw new Exception("Invalid link mode '" . $number . "'"); - } - return self::$linkModes[$number]; - } - - - public static function linkModeNameToNumber($name, $caseInsensitive=false) { - if ($caseInsensitive) { - $name = strtolower($name); - } - $number = array_search($name, self::$linkModes); - if ($number === false) { - throw new Exception("Invalid link mode name '" . $name . "'"); - } - return $number; - } - - - /** - * Download file from S3, extract it if necessary, and return a temporary URL - * pointing to the main file - */ - public static function getTemporaryURL(Zotero_Item $item, $localOnly=false) { - $extURLPrefix = Z_CONFIG::$ATTACHMENT_SERVER_URL; - if ($extURLPrefix[strlen($extURLPrefix) - 1] != "/") { - $extURLPrefix .= "/"; - } - - $info = Zotero_Storage::getLocalFileItemInfo($item); - $storageFileID = $info['storageFileID']; - $filename = $info['filename']; - $mtime = $info['mtime']; - $zip = $info['zip']; - $realFilename = preg_replace("/^storage:/", "", $item->attachmentPath); - $realFilename = self::decodeRelativeDescriptorString($realFilename); - $realEncodedFilename = rawurlencode($realFilename); - - $docroot = Z_CONFIG::$ATTACHMENT_SERVER_DOCROOT; - - // Check memcached to see if file is already extracted - $key = "attachmentServerString_" . $storageFileID . "_" . $mtime; - if ($randomStr = Z_Core::$MC->get($key)) { - Z_Core::debug("Got attachment path '$randomStr/$realEncodedFilename' from memcached"); - return $extURLPrefix . "$randomStr/$realEncodedFilename"; - } - - $localAddr = gethostbyname(gethostname()); - - // See if this is an attachment host - $index = false; - $skipHost = false; - for ($i = 0, $len = sizeOf(Z_CONFIG::$ATTACHMENT_SERVER_HOSTS); $i < $len; $i++) { - $hostAddr = gethostbyname(Z_CONFIG::$ATTACHMENT_SERVER_HOSTS[$i]); - if ($hostAddr != $localAddr) { - continue; - } - // Make a HEAD request on the local static port to make sure - // this host is actually functional - $url = "http://" . Z_CONFIG::$ATTACHMENT_SERVER_HOSTS[$i] - . ":" . Z_CONFIG::$ATTACHMENT_SERVER_STATIC_PORT . "/"; - Z_Core::debug("Making HEAD request to $url"); - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_NOBODY, 1); - curl_setopt($ch, CURLOPT_HTTPHEADER, array("Expect:")); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1); - curl_setopt($ch, CURLOPT_TIMEOUT, 2); - curl_setopt($ch, CURLOPT_HEADER, 0); // do not return HTTP headers - curl_setopt($ch, CURLOPT_RETURNTRANSFER , 1); - $response = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if ($code != 200) { - $skipHost = Z_CONFIG::$ATTACHMENT_SERVER_HOSTS[$i]; - if ($code == 0) { - Z_Core::logError("Error connecting to local attachments server"); - } - else { - Z_Core::logError("Local attachments server returned $code"); - } - break; - } - - $index = $i + 1; - break; - } - - // If not, make an internal root request to trigger the extraction on - // one of them and retrieve the temporary URL - if ($index === false) { - // Prevent redirect madness if target server doesn't think it's an - // attachment server - if ($localOnly) { - throw new Exception("Internal attachments request hit a non-attachment server"); - } - - $prefix = 'http://' . Z_CONFIG::$API_SUPER_USERNAME . - ":" . Z_CONFIG::$API_SUPER_PASSWORD . "@"; - $path = Zotero_API::getItemURI($item) . "/file/view?int=1"; - $path = preg_replace('/^[^:]+:\/\/[^\/]+/', '', $path); - $context = stream_context_create(array( - 'http' => array( - 'follow_location' => 0 - ) - )); - $url = false; - $hosts = Z_CONFIG::$ATTACHMENT_SERVER_HOSTS; - // Try in random order - shuffle($hosts); - foreach ($hosts as $host) { - // Don't try the local host again if we know it's not working - if ($host == $skipHost) { - continue; - } - $intURL = $prefix . $host . ":" . Z_CONFIG::$ATTACHMENT_SERVER_DYNAMIC_PORT . $path; - Z_Core::debug("Making GET request to $host"); - if (file_get_contents($intURL, false, $context) !== false) { - foreach ($http_response_header as $header) { - if (preg_match('/^Location:\s*(.+)$/', $header, $matches)) { - if (strpos($matches[1], $extURLPrefix) !== 0) { - throw new Exception( - "Redirect location '" . $matches[1] . "'" - . " does not begin with $extURLPrefix" - ); - } - return $matches[1]; - } - } - } - } - return false; - } - - // If this is an attachment host, do the download/extraction inline - // and generate a random number with an embedded host id. - // - // The reverse proxy routes incoming file requests to the proper hosts - // using the embedded id. - // - // A cron job deletes old attachment directories - $randomStr = rand(1000000, 2147483647); - // Seventh number is the host id - $randomStr = substr($randomStr, 0, 6) . $index . substr($randomStr, 6); - - // Download file - $dir = $docroot . $randomStr . "/"; - $downloadDir = $zip ? $dir . "ztmp/" : $dir; - - Z_Core::debug("Downloading attachment to $dir"); - - if (!mkdir($downloadDir, 0777, true)) { - throw new Exception("Unable to create directory '$downloadDir'"); - } - if ($zip) { - $response = Zotero_Storage::downloadFile($info, $downloadDir); - } - else { - $response = Zotero_Storage::downloadFile($info, $downloadDir, $realFilename); - } - if ($response) { - if ($zip) { - $success = self::extractZip($downloadDir . $info['filename'], $dir); - unlink($downloadDir . $info['filename']); - rmdir($downloadDir); - - // Make sure charset is just a string with no spaces or newlines - if (preg_match('/^[^\s]+/', trim($item->attachmentCharset), $matches)) { - $charset = $matches[0]; - } - else { - $charset = 'Off'; - } - file_put_contents($dir . ".htaccess", "AddDefaultCharset " . $charset); - } - else { - $success = true; - if (preg_match('/^[^\s]+/', trim($item->attachmentContentType), $matches)) { - $contentType = $matches[0]; - $charset = trim($item->attachmentCharset); - if (substr($charset, 0, 5) == 'text/' && preg_match('/^[^\s]+/', $charset, $matches)) { - $contentType .= '; ' . $matches[0]; - } - file_put_contents($dir . ".htaccess", "ForceType " . $contentType); - } - } - } - - if (!$response || !$success) { - return false; - } - - Z_Core::$MC->set($key, $randomStr, self::$cacheTime); - - return $extURLPrefix . "$randomStr/" . $realEncodedFilename; - } - - - // Filenames are in Mozilla's getRelativeDescriptor() format - public static function decodeRelativeDescriptorString($str) { - try { - $converted = Z_Unicode::convertCharStr2CP($str, false, true, 'hex'); - $converted = Z_Unicode::convertUTF82Char($converted); - } - catch (Exception $e) { - Z_Core::logError("Warning: " . $e->getMessage()); - return $str; - } - return $converted; - } - - - public static function encodeRelativeDescriptorString($str) { - $str = Z_Unicode::convertCharStr2UTF8($str); - // convertNumbers2Char($str, 'hex') - $str = preg_replace_callback( - "/([A-Fa-f0-9]{2})/", - function($matches) { - return Z_Unicode::hex2char($matches[0]); - }, - str_replace(" ", "", $str) - ); - - return $str; - } - - - private static function extractZip($file, $destDir) { - $za = new ZipArchive(); - $za->open($file); - - $entries = array(); - - for ($i = 0, $max = $za->numFiles; $i < $max; $i++) { - $stat = $za->statIndex($i); - // Skip files not at the top level - if ($stat['name'] != basename($stat['name'])) { - continue; - } - // Skip dot files or ztmp (which we use as temp dir) - if ($stat['name'][0] == '.' || $stat['name'] == 'ztmp') { - continue; - } - if (preg_match("/%ZB64$/", $stat['name'])) { - $filename = Z_Base64::decode(substr($stat['name'], 0, -5)); - $filename = self::decodeRelativeDescriptorString($filename); - $za->renameIndex($i, $filename); - } - else { - $filename = $stat['name']; - } - - $entries[] = $filename; - } - - $success = $za->extractTo($destDir, $entries); - - $za->close(); - - if (!$success) { - Z_Core::logError($za->getStatusString()); - } - - return $success; - } -} diff --git a/model/CharacterSets.inc.php b/model/CharacterSets.inc.php deleted file mode 100644 index dc4fb0fb..00000000 --- a/model/CharacterSets.inc.php +++ /dev/null @@ -1,178 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_CharacterSets { - private static $initialized = false; - private static $charsetIDs = array(); - private static $charsets = array(); - private static $charsetMap = []; - - private static function init() { - if (self::$initialized) { - return; - } - self::$initialized = true; - - /// - // This should match the client. - // - // From https://encoding.spec.whatwg.org/#names-and-labels - // Don't use this, use charsetMap. See below - $charsetList = [ - "utf-8" => ["unicode-1-1-utf-8", "utf-8", "utf8"], - "ibm866" => ["866", "cp866", "csibm866", "ibm866"], - "iso-8859-2" => ["csisolatin2", "iso-8859-2", "iso-ir-101", "iso8859-2", "iso88592", "iso_8859-2", "iso_8859-2:1987","l2", "latin2"], - "iso-8859-3" => ["csisolatin3", "iso-8859-3", "iso-ir-109", "iso8859-3", "iso88593", "iso_8859-3", "iso_8859-3:1988","l3", "latin3"], - "iso-8859-4" => ["csisolatin4", "iso-8859-4", "iso-ir-110", "iso8859-4", "iso88594", "iso_8859-4", "iso_8859-4:1988","l4", "latin4"], - "iso-8859-5" => ["csisolatincyrillic", "cyrillic", "iso-8859-5", "iso-ir-144", "iso8859-5", "iso88595", "iso_8859-5", "iso_8859-5:1988"], - "iso-8859-6" => ["arabic", "asmo-708", "csiso88596e", "csiso88596i", "csisolatinarabic", "ecma-114", "iso-8859-6", "iso-8859-6-e", "iso-8859-6-i", "iso-ir-127", "iso8859-6", "iso88596", "iso_8859-6", "iso_8859-6:1987"], - "iso-8859-7" => ["csisolatingreek", "ecma-118", "elot_928", "greek", "greek8", "iso-8859-7", "iso-ir-126", "iso8859-7", "iso88597", "iso_8859-7", "iso_8859-7:1987","sun_eu_greek"], - "iso-8859-8" => ["csiso88598e", "csisolatinhebrew", "hebrew", "iso-8859-8", "iso-8859-8-e", "iso-ir-138", "iso8859-8", "iso88598", "iso_8859-8", "iso_8859-8:1988","visual"], - "iso-8859-8-i" => ["csiso88598i", "iso-8859-8-i", "logical"], - "iso-8859-10" => ["csisolatin6", "iso-8859-10", "iso-ir-157", "iso8859-10", "iso885910", "l6", "latin6"], - "iso-8859-13" => ["iso-8859-13", "iso8859-13", "iso885913"], - "iso-8859-14" => ["iso-8859-14", "iso8859-14", "iso885914"], - "iso-8859-15" => ["csisolatin9", "iso-8859-15", "iso8859-15", "iso885915", "iso_8859-15", "l9"], - "iso-8859-16" => ["iso-8859-16"], - "koi8-r" => ["cskoi8r", "koi", "koi8", "koi8-r", "koi8_r"], - "koi8-u" => ["koi8-u"], - "macintosh" => ["csmacintosh", "mac", "macintosh", "x-mac-roman"], - "windows-874" => ["dos-874", "iso-8859-11", "iso8859-11", "iso885911", "tis-620", "windows-874"], - "windows-1250" => ["cp1250", "windows-1250", "x-cp1250"], - "windows-1251" => ["cp1251", "windows-1251", "x-cp1251"], - "windows-1252" => ["ansi_x3.4-1968","ascii", "cp1252", "cp819", "csisolatin1", "ibm819", "iso-8859-1", "iso-ir-100", "iso8859-1", "iso88591", "iso_8859-1", "iso_8859-1:1987","l1", "latin1", "us-ascii", "windows-1252", "x-cp1252"], - "windows-1253" => ["cp1253", "windows-1253", "x-cp1253"], - "windows-1254" => ["cp1254", "csisolatin5", "iso-8859-9", "iso-ir-148", "iso8859-9", "iso88599", "iso_8859-9", "iso_8859-9:1989","l5", "latin5", "windows-1254", "x-cp1254"], - "windows-1255" => ["cp1255", "windows-1255", "x-cp1255"], - "windows-1256" => ["cp1256", "windows-1256", "x-cp1256"], - "windows-1257" => ["cp1257", "windows-1257", "x-cp1257"], - "windows-1258" => ["cp1258", "windows-1258", "x-cp1258"], - "x-mac-cyrillic" => ["x-mac-cyrillic", "x-mac-ukrainian"], - "gbk" => ["chinese", "csgb2312", "csiso58gb231280", "gb2312", "gb_2312", "gb_2312-80", "gbk", "iso-ir-58", "x-gbk"], - "gb18030" => ["gb18030"], - "big5" => ["big5", "cn-big5", "csbig5", "x-x-big5"], - "big5-hkscs" => ["big5-hkscs"], // see https://bugzilla.mozilla.org/show_bug.cgi?id=912470 - "euc-jp" => ["cseucpkdfmtjapanese", "euc-jp", "x-euc-jp"], - "iso-2022-jp" => ["csiso2022jp", "iso-2022-jp"], - "shift_jis" => ["csshiftjis", "ms_kanji", "shift-jis", "shift_jis", "sjis", "windows-31j", "x-sjis"], - "euc-kr" => ["cseuckr", "csksc56011987", "euc-kr", "iso-ir-149", "korean", "ks_c_5601-1987", "ks_c_5601-1989", "ksc5601", "ksc_5601", "windows-949"], - "replacement" => ["csiso2022kr", "hz-gb-2312", "iso-2022-cn", "iso-2022-cn-ext", "iso-2022-kr"], - "utf-16be" => ["utf-16be"], - "utf-16le" => ["utf-16", "utf-16le"], - "x-user-defined" => ["x-user-defined"] - ]; - - // As per https://dom.spec.whatwg.org/#dom-document-characterset - $compatibilityNames = [ - "utf-8" => "UTF-8", - "ibm866" => "IBM866", - "iso-8859-2" => "ISO-8859-2", - "iso-8859-3" => "ISO-8859-3", - "iso-8859-4" => "ISO-8859-4", - "iso-8859-5" => "ISO-8859-5", - "iso-8859-6" => "ISO-8859-6", - "iso-8859-7" => "ISO-8859-7", - "iso-8859-8" => "ISO-8859-8", - "iso-8859-8-i" => "ISO-8859-8-I", - "iso-8859-10" => "ISO-8859-10", - "iso-8859-13" => "ISO-8859-13", - "iso-8859-14" => "ISO-8859-14", - "iso-8859-15" => "ISO-8859-15", - "iso-8859-16" => "ISO-8859-16", - "koi8-r" => "KOI8-R", - "koi8-u" => "KOI8-U", - "gbk" => "GBK", - "big5" => "Big5", - "euc-jp" => "EUC-JP", - "iso-2022-jp" => "ISO-2022-JP", - "shift_jis" => "Shift_JIS", - "euc-kr" => "EUC-KR", - "utf-16be" => "UTF-16BE", - "utf-16le" => "UTF-16LE" - ]; - - $charsetMap = []; - foreach ($charsetList as $canonical => $alternates) { - $charsetMap[strtolower($canonical)] = $canonical; - foreach ($alternates as $c) { - $charsetMap[strtolower($c)] = $canonical; - } - - if (!isset($compatibilityNames[$canonical])) { - $compatibilityNames[$canonical] = $canonical; - } - } - self::$charsetMap = $charsetMap; - } - - public static function getID($charsetOrCharsetID) { - if (isset(self::$charsetIDs[$charsetOrCharsetID])) { - return self::$charsetIDs[$charsetOrCharsetID]; - } - - $sql = "(SELECT charsetID FROM charsets WHERE charsetID=?) UNION - (SELECT charsetID FROM charsets WHERE charset=?) LIMIT 1"; - $charsetID = Zotero_DB::valueQuery($sql, array($charsetOrCharsetID, $charsetOrCharsetID)); - - self::$charsetIDs[$charsetOrCharsetID] = $charsetID; - - return $charsetID; - } - - - public static function getName($charsetOrCharsetID) { - if (isset(self::$charsets[$charsetOrCharsetID])) { - return self::$charsets[$charsetOrCharsetID]; - } - - $sql = "(SELECT charset FROM charsets WHERE charsetID=?) UNION - (SELECT charset FROM charsets WHERE charset=?) LIMIT 1"; - $charset = Zotero_DB::valueQuery($sql, array($charsetOrCharsetID, $charsetOrCharsetID)); - - self::$charsets[$charsetOrCharsetID] = $charset; - - return $charset; - } - - - /** - * Convert charset label to charset name - * https://encoding.spec.whatwg.org/#names-and-labels - * @param {String} charset - * @return {String|Boolean} - Normalized charset name or FALSE if not recognized - */ - public static function toCanonical($charset) { - self::init(); - - $canonical = strtolower(trim($charset)); - if (!isset(self::$charsetMap[$canonical])) { - Z_Core::debug("Unrecognized charset: " . $charset); - return false; - } - return self::$charsetMap[$canonical]; - } -} -?> diff --git a/model/Cite.inc.php b/model/Cite.inc.php deleted file mode 100644 index 93bf4f66..00000000 --- a/model/Cite.inc.php +++ /dev/null @@ -1,643 +0,0 @@ -. - - ***** 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 => 4, - 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' => "webpage", - 'instantMessage' => "personal_communication", - 'forumPost' => "webpage", - '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) { - $lk = $item->libraryID . "/" . $item->key; - - // Any query parameters that have an effect on the output - // need to be added here - $allowedParams = array( - 'style', - 'css', - 'linkwrap' - ); - $cachedParams = Z_Array::filterKeys($queryParams, $allowedParams); - - return $mode . "_" . $lk . "_" - . md5($item->etag . json_encode($cachedParams)) - . (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', - '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"; - } - 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 {}; - }*/ -} -?> diff --git a/model/ClassicDataObjects.inc.php b/model/ClassicDataObjects.inc.php deleted file mode 100644 index 7d65c8cb..00000000 --- a/model/ClassicDataObjects.inc.php +++ /dev/null @@ -1,652 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_ClassicDataObjects { - public static $objectTypes = array( - 'creator' => array('singular'=>'Creator', 'plural'=>'Creators'), - 'item' => array('singular'=>'Item', 'plural'=>'Items'), - 'collection' => array('singular'=>'Collection', 'plural'=>'Collections'), - 'search' => array('singular'=>'Search', 'plural'=>'Searches'), - 'tag' => array('singular'=>'Tag', 'plural'=>'Tags'), - 'relation' => array('singular'=>'Relation', 'plural'=>'Relations'), - 'setting' => array('singular'=>'Setting', 'plural'=>'Settings') - ); - - protected static $ZDO_object = ''; - protected static $ZDO_objects = ''; - protected static $ZDO_Object = ''; - protected static $ZDO_Objects = ''; - protected static $ZDO_id = ''; - protected static $ZDO_key = ''; - protected static $ZDO_table = ''; - protected static $ZDO_timestamp = ''; - - private static $cacheVersion = 3; - - private static $primaryDataByID = array(); - private static $primaryDataByKey = array(); - - public static function field($field) { - if (empty(static::$ZDO_object)) { - trigger_error("Object name not provided", E_USER_ERROR); - } - - switch ($field) { - case 'object': - return static::$ZDO_object; - - case 'objects': - return static::$ZDO_objects - ? static::$ZDO_objects : static::$ZDO_object . 's'; - - case 'Object': - return ucwords(static::field('object')); - - case 'Objects': - return ucwords(static::field('objects')); - - case 'id': - return static::$ZDO_id ? static::$ZDO_id : static::$ZDO_object . 'ID'; - - case 'key': - return static::$ZDO_key ? static::$ZDO_key : 'key'; - - case 'table': - return static::$ZDO_table ? static::$ZDO_table : static::field('objects'); - - case 'timestamp': - return static::$ZDO_timestamp ? static::$ZDO_timestamp : 'serverDateModified'; - } - } - - - public static function get($libraryID, $id, $skipCheck=false) { - $type = static::field('object'); - $table = static::field('table'); - $idField = static::field('id'); - - if (!$libraryID) { - throw new Exception("Library ID not set"); - } - - if (!$id) { - throw new Exception("ID not set"); - } - - if (!$skipCheck) { - $sql = "SELECT COUNT(*) FROM $table WHERE $idField=?"; - $result = Zotero_DB::valueQuery( - $sql, $id, Zotero_Shards::getByLibraryID($libraryID) - ); - if (!$result) { - return false; - } - } - - $className = "Zotero_" . ucwords($type); - $obj = new $className; - $obj->libraryID = $libraryID; - $obj->id = $id; - return $obj; - } - - - public static function getByLibraryAndKey($libraryID, $key) { - $type = static::field('object'); - $types = static::field('objects'); - - $exists = self::existsByLibraryAndKey($libraryID, $key); - if (!$exists) { - return false; - } - - switch ($type) { - case 'item': - case 'relation': - $className = "Zotero_" . ucwords($types); - return call_user_func(array($className, 'get'), $libraryID, self::$primaryDataByKey[$type][$libraryID][$key]['id']); - - // Pass skipCheck, since we've already checked for existence - case 'collection': - case 'creator': - case 'tag': - $className = "Zotero_" . ucwords($types); - return call_user_func(array($className, 'get'), $libraryID, self::$primaryDataByKey[$type][$libraryID][$key]['id'], true); - - case 'setting': - $className = "Zotero_" . ucwords($type); - $obj = new $className; - $obj->libraryID = $libraryID; - $obj->name = $key; - return $obj; - - default: - $className = "Zotero_" . ucwords($type); - $obj = new $className; - $obj->libraryID = $libraryID; - $obj->id = self::$primaryDataByKey[$type][$libraryID][$key]['id']; - return $obj; - } - } - - - public static function existsByLibraryAndKey($libraryID, $key) { - if (!$libraryID || !is_numeric($libraryID)) { - throw new Exception("libraryID '$libraryID' must be a positive integer"); - } - - $table = static::field('table'); - $id = static::field('id'); - $type = static::field('object'); - - if ($type == 'relation') { - if (!preg_match('/[a-f0-9]{32}/', $key)) { - throw new Exception("Invalid key '$key'"); - } - } - else if ($type == 'setting') { - if (!preg_match('/[a-zA-Z0-9]{1,1000}/', $key)) { - throw new Exception("Invalid key '$key'"); - } - } - else if (!preg_match('/[A-Z0-9]{8}/', $key)) { - throw new Exception("Invalid key '$key'"); - } - - return !!self::getPrimaryDataByKey($libraryID, $key); - } - - - public static function getPrimaryDataByID($libraryID, $id) { - $idCol = static::field('id'); - $type = static::field('object'); - - if (!is_numeric($id)) { - throw new Exception("Invalid id '$id'"); - } - - if (isset(self::$primaryDataByID[$type][$libraryID][$id])) { - return self::$primaryDataByID[$type][$libraryID][$id]; - } - - $sql = self::getPrimaryDataSQL() . "libraryID=? AND $idCol=?"; - $row = Zotero_DB::rowQuery( - $sql, array($libraryID, $id), Zotero_Shards::getByLibraryID($libraryID) - ); - - self::cachePrimaryData($row, $libraryID, false, $id); - - return $row; - } - - - public static function getPrimaryDataByKey($libraryID, $key) { - $type = static::field('object'); - $keyField = static::field('key'); - - if (!is_numeric($libraryID)) { - throw new Exception("Invalid libraryID '$libraryID'"); - } - if ($type == 'relation') { - if (!preg_match('/[a-f0-9]{32}/', $key)) { - throw new Exception("Invalid key '$key'"); - } - } - else if (!preg_match('/[A-Z0-9]{8}/', $key) && $type != 'setting') { - throw new Exception("Invalid key '$key'"); - } - - if (isset(self::$primaryDataByKey[$type][$libraryID][$key])) { - return self::$primaryDataByKey[$type][$libraryID][$key]; - } - - $sql = self::getPrimaryDataSQL() . "libraryID=? AND `$keyField`=?"; - $row = Zotero_DB::rowQuery( - $sql, array($libraryID, $key), Zotero_Shards::getByLibraryID($libraryID) - ); - - self::cachePrimaryData($row, $libraryID, $key); - - return $row; - } - - - public static function cachePrimaryData($row, $libraryID=false, $key=false, $id=false) { - $type = static::field('object'); - $keyField = static::field('key'); - - if (!$row && (!$libraryID || !($key || $id))) { - throw new Exception("libraryID and either key or id must be set if row is empty"); - } - - $libraryID = $row ? $row['libraryID'] : $libraryID; - - if (!isset(self::$primaryDataByKey[$type][$libraryID])) { - self::$primaryDataByKey[$type][$libraryID] = array(); - self::$primaryDataByID[$type][$libraryID] = array(); - } - - if ($row) { - $found = 0; - $expected = sizeOf(static::$primaryFields); - - foreach ($row as $field => $val) { - if (isset(static::$primaryFields[$field])) { - $found++; - } - else { - throw new Exception("Unknown $type primary data field '$field'"); - } - } - - if ($found != $expected) { - throw new Exception("$found $type primary data fields provided -- expected $expected"); - } - - self::$primaryDataByKey[$type][$libraryID][$row[$keyField]] = $row; - if (isset($row['id'])) { - self::$primaryDataByID[$type][$libraryID][$row['id']] =& self::$primaryDataByKey[$type][$libraryID][$row['key']]; - } - } - else if ($key) { - self::$primaryDataByKey[$type][$libraryID][$key] = false; - } - else if ($id) { - self::$primaryDataByID[$type][$libraryID][$id] = false; - } - } - - - public static function uncachePrimaryData($libraryID, $key) { - $type = static::field('object'); - - if (isset(self::$primaryDataByKey[$type][$libraryID][$key])) { - if (isset(self::$primaryDataByKey[$type][$libraryID][$key]['id'])) { - $id = self::$primaryDataByKey[$type][$libraryID][$key]['id']; - unset(self::$primaryDataByID[$type][$libraryID][$id]); - } - unset(self::$primaryDataByKey[$type][$libraryID][$key]); - } - } - - - // Used for unit tests - public static function clearPrimaryDataCache() { - self::$primaryDataByID = array(); - self::$primaryDataByKey = array(); - } - - - public static function getPrimaryDataSQL() { - $fields = array(); - foreach (static::$primaryFields as $field => $dbField) { - if ($dbField) { - $fields[] = $dbField . " AS `" . $field . "`"; - } - else { - $fields[] = "`" . $field . "`"; - } - } - return "SELECT " . implode(", ", $fields) . " FROM " . static::field('table') . " WHERE "; - } - - - public static function countUpdated($userID, $timestamp, $deletedCheckLimit=false) { - $table = static::field('table'); - $id = static::field('id'); - $type = static::field('object'); - $types = static::field('objects'); - - // First, see what libraries we actually need to check - - Zotero_DB::beginTransaction(); - - // All libraries with update times >= $timestamp - $updateTimes = Zotero_Libraries::getUserLibraryUpdateTimes($userID); - $updatedLibraryIDs = array(); - foreach ($updateTimes as $libraryID=>$lastUpdated) { - if ($lastUpdated >= $timestamp) { - $updatedLibraryIDs[] = $libraryID; - } - } - - $count = self::getUpdated($userID, $timestamp, $updatedLibraryIDs, true); - - // Make sure we really have fewer than 5 - if ($deletedCheckLimit < 5) { - $count += Zotero_Sync::countDeletedObjectKeys($userID, $timestamp, $updatedLibraryIDs); - } - - Zotero_DB::commit(); - - return $count; - } - - - /** - * Returns user's object ids updated since |timestamp|, keyed by libraryID, - * or count of all updated items if $countOnly is true - * - * @param int $libraryID User ID - * @param string $timestamp Unix timestamp of last sync time - * @param array $updatedLibraryIDs Libraries with updated data - * @return array|int - */ - public static function getUpdated($userID, $timestamp, $updatedLibraryIDs, $countOnly=false) { - $table = static::field('table'); - $id = static::field('id'); - $type = static::field('object'); - $types = static::field('objects'); - $timestampCol = static::field('timestamp'); - - // All joined groups have to be checked - $joinedGroupIDs = Zotero_Groups::getJoined($userID, $timestamp); - $joinedLibraryIDs = array(); - foreach ($joinedGroupIDs as $groupID) { - $joinedLibraryIDs[] = Zotero_Groups::getLibraryIDFromGroupID($groupID); - } - - // Separate libraries into shards for querying - $libraryIDs = array_unique(array_merge($joinedLibraryIDs, $updatedLibraryIDs)); - $shardLibraryIDs = array(); - foreach ($libraryIDs as $libraryID) { - $shardID = Zotero_Shards::getByLibraryID($libraryID); - if (!isset($shardLibraryIDs[$shardID])) { - $shardLibraryIDs[$shardID] = array( - 'updated' => array(), - 'joined' => array() - ); - } - if (in_array($libraryID, $joinedLibraryIDs)) { - $shardLibraryIDs[$shardID]['joined'][] = $libraryID; - } - else { - $shardLibraryIDs[$shardID]['updated'][] = $libraryID; - } - } - - if ($countOnly) { - $count = 0; - $fieldList = "COUNT(*)"; - } - else { - $updatedByLibraryID = array(); - $fieldList = "libraryID, $id AS id"; - } - - // Send query at each shard - foreach ($shardLibraryIDs as $shardID=>$libraryIDs) { - $sql = "SELECT $fieldList FROM $table WHERE "; - if ($libraryIDs['updated']) { - $sql .= "(libraryID IN (" . implode(', ', array_fill(0, sizeOf($libraryIDs['updated']), '?')) . ")"; - $params = $libraryIDs['updated']; - $sql .= " AND $timestampCol >= FROM_UNIXTIME(?))"; - $params[] = $timestamp; - } - - if ($libraryIDs['joined']) { - if ($libraryIDs['updated']) { - $sql .= " OR "; - } - else { - $params = array(); - } - $sql .= "libraryID IN (" . implode(', ', array_fill(0, sizeOf($libraryIDs['joined']), '?')) . ")"; - $params = array_merge($params, $libraryIDs['joined']); - } - - if ($countOnly) { - $count += Zotero_DB::valueQuery($sql, $params, $shardID); - } - else { - $rows = Zotero_DB::query($sql, $params, $shardID); - if ($rows) { - // Separate ids by libraryID - foreach ($rows as $row) { - $updatedByLibraryID[$row['libraryID']][] = $row['id']; - } - } - } - } - - return $countOnly ? $count : $updatedByLibraryID; - } - - - public static function getDeleteLogKeys($libraryID, $version, $versionIsTimestamp=false) { - // Default empty library - if ($libraryID === 0) { - return []; - } - - $type = static::field('object'); - - // TEMP: until classic syncing is deprecated and the objectType - // 'tagName' is changed to 'tag' - if ($type == 'tag') { - $type = 'tagName'; - } - - $sql = "SELECT `key` FROM syncDeleteLogKeys " - . "WHERE objectType=? AND libraryID=? AND "; - // TEMP: sync transition - $sql .= $versionIsTimestamp ? "timestamp>=FROM_UNIXTIME(?)" : "version>?"; - $keys = Zotero_DB::columnQuery( - $sql, - array($type, $libraryID, $version), - Zotero_Shards::getByLibraryID($libraryID) - ); - if (!$keys) { - return array(); - } - return $keys; - } - - - protected static function validateMultiObjectJSON($json, $requestParams) { - $objectTypePlural = static::field('objects'); - - if ($requestParams['v'] < 3) { - if (!is_object($json)) { - throw new Exception('Uploaded data must be a JSON object', Z_ERROR_INVALID_INPUT); - } - - // Multiple-object format - if (isset($json->$objectTypePlural)) { - if (!is_array($json->$objectTypePlural)) { - throw new Exception("'$objectTypePlural' must be an array", Z_ERROR_INVALID_INPUT); - } - foreach ($json as $key=>$val) { - if ($key != $objectTypePlural) { - throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT); - } - $maxWriteKey = "maxWrite" . ucwords($objectTypePlural); - if (sizeOf($val) > Zotero_API::$$maxWriteKey) { - throw new Exception("Cannot add more than " - . Zotero_API::$$maxWriteKey - . " $objectTypePlural at a time", Z_ERROR_UPLOAD_TOO_LARGE); - } - } - } - // Single-collection format (collections only) - else if ($requestParams['v'] < 2 && $objectTypePlural == 'collections') { - if (!isset($json->name)) { - throw new Exception("'collections' or 'name' must be provided", Z_ERROR_INVALID_INPUT); - } - } - else { - throw new Exception("'$objectTypePlural' must be provided", Z_ERROR_INVALID_INPUT); - } - - return; - } - - if (!is_array($json)) { - throw new Exception('Uploaded data must be a JSON array', Z_ERROR_INVALID_INPUT); - } - $maxWriteKey = "maxWrite" . ucwords($objectTypePlural); - if (sizeOf($json) > Zotero_API::$$maxWriteKey) { - throw new Exception("Cannot add more than " - . Zotero_API::$$maxWriteKey - . " $objectTypePlural at a time", Z_ERROR_UPLOAD_TOO_LARGE); - } - } - - - public static function delete($libraryID, $key, $userID=false) { - $table = static::field('table'); - $id = static::field('id'); - $type = static::field('object'); - $types = static::field('objects'); - $keyField = static::field('key'); - - if (!$key) { - throw new Exception("Invalid key $key"); - } - - // Get object (and trigger caching) - $obj = static::getByLibraryAndKey($libraryID, $key); - if (!$obj) { - return; - } - static::editCheck($obj); - - Z_Core::debug("Deleting $type $libraryID/$key", 4); - - $shardID = Zotero_Shards::getByLibraryID($libraryID); - - Zotero_DB::beginTransaction(); - - // Delete child items - if ($type == 'item') { - if ($obj->isRegularItem()) { - $children = array_merge($obj->getNotes(), $obj->getAttachments()); - if ($children) { - $children = Zotero_Items::get($libraryID, $children); - foreach ($children as $child) { - static::delete($child->libraryID, $child->key); - } - } - } - - // Remove relations (except for merge tracker) - $uri = Zotero_URI::getItemURI($obj); - Zotero_Relations::eraseByURI( - $libraryID, $uri, array(Zotero_Relations::$deletedItemPredicate) - ); - } - else if ($type == 'tag') { - // Tag deletions need to stored by tag for the API - $tagName = $obj->name; - - // Update linked items - Zotero_Items::updateVersions($obj->getLinkedItems(), $userID); - } - - if ($type == 'item' && $obj->isAttachment()) { - Zotero_FullText::deleteItemContent($obj); - } - - $sql = "DELETE FROM $table WHERE libraryID=? AND `$keyField`=?"; - $deleted = Zotero_DB::query($sql, array($libraryID, $key), $shardID); - - static::uncachePrimaryData($libraryID, $key); - - if ($deleted) { - $sql = "INSERT INTO syncDeleteLogKeys - (libraryID, objectType, `key`, timestamp, version) - VALUES (?, '$type', ?, ?, ?) - ON DUPLICATE KEY UPDATE timestamp=?, version=?"; - $timestamp = Zotero_DB::getTransactionTimestamp(); - $version = Zotero_Libraries::getUpdatedVersion($libraryID); - $params = array( - $libraryID, $key, $timestamp, $version, $timestamp, $version - ); - Zotero_DB::query($sql, $params, $shardID); - - if ($type == 'tag') { - $sql = "INSERT INTO syncDeleteLogKeys - (libraryID, objectType, `key`, timestamp, version) - VALUES (?, 'tagName', ?, ?, ?) - ON DUPLICATE KEY UPDATE timestamp=?, version=?"; - $params = array( - $libraryID, $tagName, $timestamp, $version, $timestamp, $version - ); - Zotero_DB::query($sql, $params, $shardID); - } - } - - Zotero_DB::commit(); - } - - - /** - * @param SimpleXMLElement $xml Data necessary for delete as SimpleXML element - * @return void - */ - public static function deleteFromXML(SimpleXMLElement $xml, $userID) { - $parents = array(); - - foreach ($xml->children() as $obj) { - $libraryID = (int) $obj['libraryID']; - $key = (string) $obj['key']; - - if ($userID && !Zotero_Libraries::userCanEdit($libraryID, $userID)) { - throw new Exception("Cannot edit " . static::field('object') - . " in library $libraryID", Z_ERROR_LIBRARY_ACCESS_DENIED); - } - - if ($obj->getName() == 'item') { - $item = Zotero_Items::getByLibraryAndKey($libraryID, $key); - if (!$item) { - continue; - } - if (!$item->getSource()) { - $parents[] = array('libraryID' => $libraryID, 'key' => $key); - continue; - } - } - static::delete($libraryID, $key); - } - - foreach ($parents as $obj) { - static::delete($obj['libraryID'], $obj['key']); - } - } - - - - public static function editCheck($obj, $userID=false) { - if (!$userID) { - return true; - } - - if (!Zotero_Libraries::userCanEdit($obj->libraryID, $userID, $obj)) { - throw new Exception("Cannot edit " . static::field('object') - . " in library $obj->libraryID", Z_ERROR_LIBRARY_ACCESS_DENIED); - } - } -} diff --git a/model/Collection.inc.php b/model/Collection.inc.php deleted file mode 100644 index d8e52447..00000000 --- a/model/Collection.inc.php +++ /dev/null @@ -1,914 +0,0 @@ -. - - ***** 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 'relations': - return $this->getRelations(); - - 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->parent = 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 item to the parent's cached item 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] - ) - ); - } - } - } - } - 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)); - - // - // TODO: remove UPDATE statements below once classic syncing is removed - // - // Update timestamp of collection - $sql = "UPDATE collections SET serverDateModified=? WHERE collectionID=?"; - $ts = Zotero_DB::getTransactionTimestamp(); - Zotero_DB::query($sql, array($ts, $this->id), $shardID); - - // Update version of new and removed items - if ($new || $removed) { - $sql = "UPDATE items SET version=? WHERE itemID IN (" - . implode(', ', array_fill(0, sizeOf($new) + sizeOf($removed), '?')) - . ")"; - Zotero_DB::query( - $sql, - array_merge( - array(Zotero_Libraries::getUpdatedVersion($this->libraryID)), - $new, - $removed - ), - $shardID - ); - } - - 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($requestParams=[]) { - $t = microtime(true); - - // Child collections and items can't be cached (easily) - $numCollections = $this->numCollections(); - $numItems = $this->numItems(); - - if (!$requestParams['uncached']) { - $cacheKey = $this->getCacheKey($requestParams); - $cached = Z_Core::$MC->get($cacheKey); - if ($cached) { - Z_Core::debug("Using cached JSON for $this->libraryKey"); - $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' => Zotero_Libraries::toJSON($this->libraryID) - ]; - - // '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/atom+xml" - ]; - } - - // '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); - } - } - - if (!$requestParams['uncached']) { - Z_Core::$MC->set($cacheKey, $json); - - 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(); - } - 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) { - $cacheKey = implode("\n", [ - $this->libraryID, - $this->key, - $this->version, - implode(',', $requestParams['include']), - $requestParams['v'] - ]); - return md5($cacheKey); - } - - - 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); - } -} -?> diff --git a/model/Collections.inc.php b/model/Collections.inc.php deleted file mode 100644 index 01c206b0..00000000 --- a/model/Collections.inc.php +++ /dev/null @@ -1,541 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_Collections { - use Zotero_DataObjects; - - private static $objectType = 'collection'; - private static $primaryDataSQLParts = [ - 'id' => 'O.collectionID', - 'name' => 'O.collectionName', - 'libraryID' => 'O.libraryID', - 'key' => 'O.key', - 'version' => 'O.version', - 'dateAdded' => 'O.dateAdded', - 'dateModified' => 'O.dateModified', - 'parentID' => 'O.parentCollectionID', - 'parentKey' => 'CP.key' - ]; - private static $_primaryDataSQLFrom = 'FROM collections O LEFT JOIN collections CP ON (O.parentCollectionID=CP.collectionID)'; - - public static $maxLength = 255; - - public static function search($libraryID, $onlyTopLevel=false, $params) { - $results = array('results' => array(), 'total' => 0); - - $shardID = Zotero_Shards::getByLibraryID($libraryID); - - $sql = "SELECT SQL_CALC_FOUND_ROWS DISTINCT "; - if ($params['format'] == 'keys') { - $sql .= "`key`"; - } - else { - $sql .= "`key`, version"; - } - $sql .= " FROM collections WHERE libraryID=? "; - $sqlParams = array($libraryID); - - if ($onlyTopLevel) { - $sql .= "AND parentCollectionID IS NULL "; - } - - // Pass a list of collectionIDs, for when the initial search is done via SQL - $collectionIDs = !empty($params['collectionIDs']) - ? $params['collectionIDs'] : array(); - $collectionKeys = $params['collectionKey']; - - if ($collectionIDs) { - $sql .= "AND collectionID IN (" - . implode(', ', array_fill(0, sizeOf($collectionIDs), '?')) - . ") "; - $sqlParams = array_merge($sqlParams, $collectionIDs); - } - - if ($collectionKeys) { - $sql .= "AND `key` IN (" - . implode(', ', array_fill(0, sizeOf($collectionKeys), '?')) - . ") "; - $sqlParams = array_merge($sqlParams, $collectionKeys); - } - - if (!empty($params['q'])) { - $sql .= "AND collectionName LIKE ? "; - $sqlParams[] = '%' . $params['q'] . '%'; - } - - if (!empty($params['since'])) { - $sql .= "AND version > ? "; - $sqlParams[] = $params['since']; - } - - // TEMP: for sync transition - if (!empty($params['sincetime'])) { - $sql .= "AND serverDateModified >= FROM_UNIXTIME(?) "; - $sqlParams[] = $params['sincetime']; - } - - if (!empty($params['sort'])) { - switch ($params['sort']) { - case 'title': - $orderSQL = 'collectionName'; - break; - - case 'collectionKeyList': - $orderSQL = "FIELD(`key`," - . implode(',', array_fill(0, sizeOf($collectionKeys), '?')) . ")"; - $sqlParams = array_merge($sqlParams, $collectionKeys); - break; - - default: - $orderSQL = $params['sort']; - } - - $sql .= "ORDER BY $orderSQL"; - if (!empty($params['direction'])) { - $sql .= " {$params['direction']}"; - } - $sql .= ", "; - } - $sql .= "version " . (!empty($params['direction']) ? $params['direction'] : "ASC") - . ", collectionID " . (!empty($params['direction']) ? $params['direction'] : "ASC") . " "; - - if (!empty($params['limit'])) { - $sql .= "LIMIT ?, ?"; - $sqlParams[] = $params['start'] ? $params['start'] : 0; - $sqlParams[] = $params['limit']; - } - - if ($params['format'] == 'keys') { - $rows = Zotero_DB::columnQuery($sql, $sqlParams, $shardID); - } - // Keys and versions - else { - $rows = Zotero_DB::query($sql, $sqlParams, $shardID); - } - - $results['total'] = Zotero_DB::valueQuery("SELECT FOUND_ROWS()", false, $shardID); - if ($rows) { - if ($params['format'] == 'keys') { - $results['results'] = $rows; - } - else if ($params['format'] == 'versions') { - foreach ($rows as $row) { - $results['results'][$row['key']] = $row['version']; - } - } - else { - $collections = []; - foreach ($rows as $row) { - $obj = self::getByLibraryAndKey($libraryID, $row['key']); - $obj->setAvailableVersion($row['version']); - $collections[] = $obj; - } - $results['results'] = $collections; - } - } - - return $results; - } - - - public static function getLongDataValueFromXML(DOMDocument $doc) { - $xpath = new DOMXPath($doc); - $attr = $xpath->evaluate('//collections/collection[string-length(@name) > ' . self::$maxLength . ']/@name'); - return $attr->length ? $attr->item(0)->value : false; - } - - - /** - * Converts a DOMElement item to a Zotero_Collection object - * - * @param DOMElement $xml Collection data as DOMElement - * @return Zotero_Collection Zotero collection object - */ - public static function convertXMLToCollection(DOMElement $xml) { - $libraryID = (int) $xml->getAttribute('libraryID'); - $col = self::getByLibraryAndKey($libraryID, $xml->getAttribute('key')); - if (!$col) { - $col = new Zotero_Collection; - $col->libraryID = $libraryID; - $col->key = $xml->getAttribute('key'); - } - $col->name = $xml->getAttribute('name'); - $parentKey = $xml->getAttribute('parent'); - if ($parentKey) { - $col->parentKey = $parentKey; - } - else { - $col->parentKey = false; - } - $col->dateAdded = $xml->getAttribute('dateAdded'); - $col->dateModified = $xml->getAttribute('dateModified'); - - // TODO: move from SyncController? - - return $col; - } - - - /** - * Converts a Zotero_Collection object to a SimpleXMLElement item - * - * @param object $item Zotero_Collection object - * @return SimpleXMLElement Collection data as SimpleXML element - */ - public static function convertCollectionToXML(Zotero_Collection $collection) { - $xml = new SimpleXMLElement(''); - $xml['libraryID'] = $collection->libraryID; - $xml['key'] = $collection->key; - $xml['name'] = $collection->name; - $xml['dateAdded'] = $collection->dateAdded; - $xml['dateModified'] = $collection->dateModified; - if ($collection->parentID) { - $parentCol = self::get($collection->libraryID, $collection->parentID); - $xml['parent'] = $parentCol->key; - } - - $children = $collection->getChildren(); - if ($children) { - $keys = array(); - foreach($children as $child) { - if ($child['type'] == 'item') { - $keys[] = $child['key']; - } - } - - if ($keys) { - $xml->items = implode(' ', $keys); - } - } - - return $xml; - } - - - /** - * Converts a Zotero_Collection object to a SimpleXMLElement Atom object - * - * @param Zotero_Collection $collection Zotero_Collection object - * @param array $requestParams - * @return SimpleXMLElement Collection data as SimpleXML element - */ - public static function convertCollectionToAtom(Zotero_Collection $collection, $requestParams) { - // TEMP: multi-format support - if (!empty($requestParams['content'])) { - $content = $requestParams['content']; - } - else { - $content = array('none'); - } - $content = $content[0]; - - $xml = new SimpleXMLElement( - '' - . '' - ); - - $title = $collection->name ? $collection->name : '[Untitled]'; - $xml->title = $title; - - $author = $xml->addChild('author'); - // TODO: group item creator - $author->name = Zotero_Libraries::getName($collection->libraryID); - $author->uri = Zotero_URI::getLibraryURI($collection->libraryID, true); - - $xml->id = Zotero_URI::getCollectionURI($collection); - - $xml->published = Zotero_Date::sqlToISO8601($collection->dateAdded); - $xml->updated = Zotero_Date::sqlToISO8601($collection->dateModified); - - $link = $xml->addChild("link"); - $link['rel'] = "self"; - $link['type'] = "application/atom+xml"; - $link['href'] = Zotero_API::getCollectionURI($collection); - - $parentID = $collection->parentID; - if ($parentID) { - $parentCol = self::get($collection->libraryID, $parentID); - $link = $xml->addChild("link"); - $link['rel'] = "up"; - $link['type'] = "application/atom+xml"; - $link['href'] = Zotero_API::getCollectionURI($parentCol); - } - - $link = $xml->addChild('link'); - $link['rel'] = 'alternate'; - $link['type'] = 'text/html'; - $link['href'] = Zotero_URI::getCollectionURI($collection, true); - - $xml->addChild('zapi:key', $collection->key, Zotero_Atom::$nsZoteroAPI); - $xml->addChild('zapi:version', $collection->version, Zotero_Atom::$nsZoteroAPI); - - $collections = $collection->getChildCollections(); - $xml->addChild( - 'zapi:numCollections', - sizeOf($collections), - Zotero_Atom::$nsZoteroAPI - ); - $xml->addChild( - 'zapi:numItems', - $collection->numItems(), - Zotero_Atom::$nsZoteroAPI - ); - - if ($content == 'json') { - $xml->content['type'] = 'application/json'; - // Deprecated - if ($requestParams['v'] < 2) { - $xml->content->addAttribute( - 'zapi:etag', - $collection->etag, - Zotero_Atom::$nsZoteroAPI - ); - $xml->content['etag'] = $collection->etag; - } - $xml->content = Zotero_Utilities::formatJSON($collection->toJSON($requestParams)); - } - - return $xml; - } - - - /** - * @param Zotero_Collection $collection The collection object to update; - * this should be either an existing - * collection or a new collection - * with a library assigned. - * @param object $json Collection data to write - * @param boolean [$requireVersion=0] See Zotero_API::checkJSONObjectVersion() - * @return boolean True if the collection was changed, false otherwise - */ - public static function updateFromJSON(Zotero_Collection $collection, - $json, - $requestParams, - $userID, - $requireVersion=0, - $partialUpdate=false) { - $json = Zotero_API::extractEditableJSON($json); - $exists = Zotero_API::processJSONObjectKey($collection, $json, $requestParams); - Zotero_API::checkJSONObjectVersion($collection, $json, $requestParams, $requireVersion); - self::validateJSONCollection($json, $requestParams, $partialUpdate && $exists); - - $changed = false; - - if (!Zotero_DB::transactionInProgress()) { - Zotero_DB::beginTransaction(); - $transactionStarted = true; - } - else { - $transactionStarted = false; - } - - if (isset($json->name)) { - $collection->name = $json->name; - } - - if ($requestParams['v'] >= 2 && isset($json->parentCollection)) { - $collection->parentKey = $json->parentCollection; - } - else if ($requestParams['v'] < 2 && isset($json->parent)) { - $collection->parentKey = $json->parent; - } - else if (!$partialUpdate) { - $collection->parentKey = false; - } - - if ($requestParams['v'] >= 2) { - if (isset($json->relations)) { - $changed = $collection->setRelations($json->relations, $userID) || $changed; - } - else if (!$partialUpdate) { - $changed = $collection->setRelations(new stdClass(), $userID) || $changed; - } - } - - $changed = $collection->save() || $changed; - - if ($transactionStarted) { - Zotero_DB::commit(); - } - - return $changed; - } - - - public static function registerChildCollection($collectionID, $childCollectionID) { - if (self::$objectCache[$collectionID]) { - self::$objectCache[$collectionID]->registerChildCollection($childCollectionID); - } - } - - - public static function unregisterChildCollection($collectionID, $childCollectionID) { - if (self::$objectCache[$collectionID]) { - self::$objectCache[$collectionID]->unregisterChildCollection($childCollectionID); - } - } - - - public static function registerChildItem($collectionID, $itemID) { - if (self::$objectCache[$collectionID]) { - self::$objectCache[$collectionID]->registerChildItem($itemID); - } - } - - - public static function unregisterChildItem($collectionID, $itemID) { - if (self::$objectCache[$collectionID]) { - self::$objectCache[$collectionID]->unregisterChildItem($itemID); - } - } - - - private static function validateJSONCollection($json, $requestParams, $partialUpdate=false) { - if (!is_object($json)) { - throw new Exception('$json must be a decoded JSON object'); - } - - if ($partialUpdate) { - $requiredProps = []; - } - else { - $requiredProps = ['name']; - } - - foreach ($requiredProps as $prop) { - if (!isset($json->$prop)) { - throw new Exception("'$prop' property not provided", Z_ERROR_INVALID_INPUT); - } - } - - foreach ($json as $key=>$val) { - switch ($key) { - // Handled by Zotero_API::checkJSONObjectVersion() - case 'key': - case 'version': - case 'collectionKey': - case 'collectionVersion': - break; - - case 'name': - if (!is_string($val)) { - throw new Exception("'name' must be a string", Z_ERROR_INVALID_INPUT); - } - - if ($val === "") { - throw new Exception("Collection name cannot be empty", Z_ERROR_INVALID_INPUT); - } - - if (mb_strlen($val) > 255) { - throw new Exception("=Collection name '" . mb_substr($val, 0, 50) . "…' " - . "too long", Z_ERROR_COLLECTION_TOO_LONG); - } - break; - - case 'parent': - if ($requestParams['v'] >= 2) { - throw new Exception("'parent' property is now 'parentCollection'", Z_ERROR_INVALID_INPUT); - } - if (!is_string($val) && !empty($val)) { - throw new Exception("'$key' must be a collection key or FALSE (" . gettype($val) . ")", Z_ERROR_INVALID_INPUT); - } - break; - - case 'parentCollection': - if ($requestParams['v'] < 2) { - throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT); - } - if (!is_string($val) && !empty($val)) { - throw new Exception("'$key' must be a collection key or FALSE (" . gettype($val) . ")", Z_ERROR_INVALID_INPUT); - } - break; - - case 'relations': - if ($requestParams['v'] < 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::$allowedCollectionPredicates)) { - 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\/)?collections\/[A-Z0-9]{8}$/', $uri)) { - throw new Exception("'$key' values currently must be Zotero collection URIs", Z_ERROR_INVALID_INPUT); - } - } - } - break; - - default: - throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT); - } - } - } - - - public static function deleteSubcollections($libraryID, $key) { - $shardID = Zotero_Shards::getByLibraryID($libraryID); - - $sql = "SELECT C1.collectionID FROM collections C1 " - . "JOIN collections C2 ON (C1.parentCollectionID=C2.collectionID) " - . "WHERE C2.libraryID=? AND C2.key=?"; - $childCollectionID = Zotero_DB::valueQuery($sql, [$libraryID, $key], $shardID); - if (!$childCollectionID) { - error_log("NO"); - return 0; - } - - $collectionIDs = [$childCollectionID]; - $sql = "SELECT collectionID FROM collections WHERE parentCollectionID=?"; - while ($childCollectionID = Zotero_DB::valueQuery($sql, [$childCollectionID], $shardID)) { - $collectionIDs[] = $childCollectionID; - } - $numDeleted = sizeOf($collectionIDs); - - // Delete from bottom up - while ($id = array_pop($collectionIDs)) { - $sql = "DELETE FROM collections WHERE collectionID=?"; - Zotero_DB::query($sql, [$id], $shardID); - } - return $numDeleted; - } -} - -Zotero_Collections::init(); diff --git a/model/Creator.inc.php b/model/Creator.inc.php deleted file mode 100644 index a60c3268..00000000 --- a/model/Creator.inc.php +++ /dev/null @@ -1,385 +0,0 @@ -. - - ***** 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'"); - } -} -?> diff --git a/model/CreatorTypes.inc.php b/model/CreatorTypes.inc.php deleted file mode 100644 index e3de1ea8..00000000 --- a/model/CreatorTypes.inc.php +++ /dev/null @@ -1,242 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_CreatorTypes { - private static $typeIDs = array(); - private static $typeNames = array(); - private static $primaryIDCache = array(); - private static $creatorTypesByItemType = array(); - private static $isValidForItemType = array(); - - private static $localizedStrings = array( - "author" => "Author", - "contributor" => "Contributor", - "editor" => "Editor", - "translator" => "Translator", - "seriesEditor" => "Series Editor", - "interviewee" => "Interview With", - "interviewer" => "Interviewer", - "director" => "Director", - "scriptwriter" => "Scriptwriter", - "producer" => "Producer", - "castMember" => "Cast Member", - "sponsor" => "Sponsor", - "counsel" => "Counsel", - "inventor" => "Inventor", - "attorneyAgent" => "Attorney/Agent", - "recipient" => "Recipient", - "performer" => "Performer", - "composer" => "Composer", - "wordsBy" => "Words By", - "cartographer" => "Cartographer", - "programmer" => "Programmer", - "reviewedAuthor" => "Reviewed Author", - "artist" => "Artist", - "commenter" => "Commenter", - "presenter" => "Presenter", - "guest" => "Guest", - "podcaster" => "Podcaster", - "reviewedAuthor" => "Reviewed Author", - "cosponsor" => "Cosponsor", - "bookAuthor" => "Book Author" - ); - - public static function getID($typeOrTypeID) { - if (isset(self::$typeIDs[$typeOrTypeID])) { - return self::$typeIDs[$typeOrTypeID]; - } - - $cacheKey = "creatorTypeID_" . $typeOrTypeID; - $typeID = Z_Core::$MC->get($cacheKey); - if ($typeID) { - // casts are temporary until memcached reload - self::$typeIDs[$typeOrTypeID] = (int) $typeID; - return (int) $typeID; - } - - $sql = "(SELECT creatorTypeID FROM creatorTypes WHERE creatorTypeID=?) UNION - (SELECT creatorTypeID FROM creatorTypes WHERE creatorTypeName=?) LIMIT 1"; - $typeID = Zotero_DB::valueQuery($sql, array($typeOrTypeID, $typeOrTypeID)); - - self::$typeIDs[$typeOrTypeID] = $typeID ? (int) $typeID : false; - Z_Core::$MC->set($cacheKey, (int) $typeID); - - return (int) $typeID; - } - - - public static function getName($typeOrTypeID) { - if (isset(self::$typeNames[$typeOrTypeID])) { - return self::$typeNames[$typeOrTypeID]; - } - - $cacheKey = "creatorTypeName_" . $typeOrTypeID; - $typeName = Z_Core::$MC->get($cacheKey); - if ($typeName) { - self::$typeNames[$typeOrTypeID] = $typeName; - return $typeName; - } - - $sql = "(SELECT creatorTypeName FROM creatorTypes WHERE creatorTypeID=?) UNION - (SELECT creatorTypeName FROM creatorTypes WHERE creatorTypeName=?) LIMIT 1"; - $typeName = Zotero_DB::valueQuery($sql, array($typeOrTypeID, $typeOrTypeID)); - - self::$typeNames[$typeOrTypeID] = $typeName; - Z_Core::$MC->set($cacheKey, $typeName); - - return $typeName; - } - - - public static function getLocalizedString($typeOrTypeID, $locale='en-US') { - if ($locale != 'en-US') { - throw new Exception("Locale not yet supported"); - } - $type = self::getName($typeOrTypeID); - return self::$localizedStrings[$type]; - } - - - public static function getTypesForItemType($itemTypeID, $locale=false) { - if (isset(self::$creatorTypesByItemType[$itemTypeID])) { - return self::$creatorTypesByItemType[$itemTypeID]; - } - - $sql = "SELECT creatorTypeID AS id, creatorTypeName AS name - FROM itemTypeCreatorTypes NATURAL JOIN creatorTypes - WHERE itemTypeID=? ORDER BY primaryField=1 DESC, name"; - $rows = Zotero_DB::query($sql, $itemTypeID); - if (!$rows) { - $rows = array(); - } - - if (!$locale) { - self::$creatorTypesByItemType[$itemTypeID] = $rows; - return $rows; - } - - foreach ($rows as &$row) { - $row['localized'] = self::getLocalizedString($row['id'], $locale); - } - - $primary = array_shift($rows); - - usort($rows, function ($a, $b) { - return strcmp($a["localized"], $b["localized"]); - }); - - array_unshift($rows, $primary); - - self::$creatorTypesByItemType[$itemTypeID] = $rows; - - return $rows; - } - - - public static function isValidForItemType($creatorTypeID, $itemTypeID) { - if (isset(self::$isValidForItemType[$itemTypeID][$creatorTypeID])) { - return self::$isValidForItemType[$itemTypeID][$creatorTypeID]; - } - - $valid = false; - $types = self::getTypesForItemType($itemTypeID); - foreach ($types as $type) { - if ($type['id'] == $creatorTypeID) { - $valid = true; - break; - } - } - - if (!isset(self::$isValidForItemType[$itemTypeID])) { - self::$isValidForItemType[$itemTypeID] = array(); - } - self::$isValidForItemType[$itemTypeID][$creatorTypeID] = $valid; - return $valid; - } - - - public static function getPrimaryIDForType($itemTypeID) { - // Check local cache - if (isset(self::$primaryIDCache[$itemTypeID])) { - return self::$primaryIDCache[$itemTypeID]; - } - - // Check memcached - $cacheKey = "primaryCreatorTypeID_" . $itemTypeID; - $creatorTypeID = Z_Core::$MC->get($cacheKey); - if ($creatorTypeID) { - self::$primaryIDCache[$itemTypeID] = $creatorTypeID; - return $creatorTypeID; - } - - - $sql = "SELECT creatorTypeID FROM itemTypeCreatorTypes - WHERE itemTypeID=? AND primaryField=1"; - $creatorTypeID = Zotero_DB::valueQuery($sql, $itemTypeID); - - // Store in local cache and memcached - self::$primaryIDCache[$itemTypeID] = $creatorTypeID; - Z_Core::$MC->set($cacheKey, $creatorTypeID); - - return $creatorTypeID; - } - - - public static function isCustomType($creatorTypeID) { - $sql = "SELECT custom FROM creatorTypes WHERE creatorTypeID=?"; - $isCustom = Zotero_DB::valueQuery($sql, $creatorTypeID); - if ($isCustom === false) { - trigger_error("Invalid creatorTypeID '$creatorTypeID'", E_USER_ERROR); - } - return !!$isCustom; - } - - - public static function addCustomType($name) { - if (self::getID($name)) { - trigger_error("Item type '$name' already exists", E_USER_ERROR); - } - - if (!preg_match('/^[a-z][^\s0-9]+$/', $name)) { - trigger_error("Invalid item type name '$name'", E_USER_ERROR); - } - - // TODO: make sure user hasn't added too many already - - Zotero_DB::beginTransaction(); - - $sql = "SELECT NEXT_ID(creatorTypeID) FROM creatorTypes"; - $creatorTypeID = Zotero_DB::valueQuery($sql); - - $sql = "INSERT INTO creatorTypes (?, ?, ?)"; - Zotero_DB::query($sql, array($creatorTypeID, $name, 1)); - - Zotero_DB::commit(); - - return $creatorTypeID; - } -} -?> diff --git a/model/Creators.inc.php b/model/Creators.inc.php deleted file mode 100644 index a05dfe11..00000000 --- a/model/Creators.inc.php +++ /dev/null @@ -1,295 +0,0 @@ -. - - ***** 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 FROM creators "; - if ($sortByItemCountDesc) { - $sql .= "LEFT JOIN itemCreators USING (creatorID) "; - } - $sql .= "WHERE libraryID=? AND firstName COLLATE utf8mb4_bin = ? " - . "AND lastName COLLATE utf8mb4_bin = ? AND fieldMode=?"; - if ($sortByItemCountDesc) { - $sql .= " GROUP BY creatorID ORDER BY IFNULL(COUNT(*), 0) DESC"; - } - $ids = Zotero_DB::columnQuery( - $sql, - array( - $libraryID, - $creator->firstName, - $creator->lastName, - $creator->fieldMode - ), - Zotero_Shards::getByLibraryID($libraryID) - ); - return $ids; - } - - - public static function getDataValuesFromXML(DOMDocument $doc) { - $xpath = new DOMXPath($doc); - $nodes = $xpath->evaluate('//creators/creator'); - $objs = array(); - foreach ($nodes as $n) { - $objs[] = self::convertXMLToDataValues($n); - } - return $objs; - } - - - public static function getLongDataValueFromXML(DOMDocument $doc) { - $xpath = new DOMXPath($doc); - $names = $xpath->evaluate( - '//creators/creator[string-length(name) > ' . self::$maxLastNameLength . ']/name ' - . '| //creators/creator[string-length(firstName) > ' . self::$maxFirstNameLength . ']/firstName ' - . '| //creators/creator[string-length(lastName) > ' . self::$maxLastNameLength . ']/lastName ' - ); - return $names->length ? $names->item(0) : false; - } - - - /** - * Converts a SimpleXMLElement item to a Zotero_Item object - * - * @param DOMElement $xml Item data as DOMElement - * @return Zotero_Creator Zotero creator object - */ - public static function convertXMLToCreator(DOMElement $xml) { - $libraryID = (int) $xml->getAttribute('libraryID'); - $creatorObj = self::getByLibraryAndKey($libraryID, $xml->getAttribute('key')); - // Not an existing item, so create - if (!$creatorObj) { - $creatorObj = new Zotero_Creator; - $creatorObj->libraryID = $libraryID; - $creatorObj->key = $xml->getAttribute('key'); - } - $creatorObj->dateAdded = $xml->getAttribute('dateAdded'); - $creatorObj->dateModified = $xml->getAttribute('dateModified'); - - $dataObj = self::convertXMLToDataValues($xml); - foreach ($dataObj as $key => $val) { - $creatorObj->$key = $val; - } - - return $creatorObj; - } - - - /** - * Converts a Zotero_Creator object to a DOMElement - * - * @param object $item Zotero_Creator object - * @return DOMElement Creator data as DOMElement element - */ - public static function convertCreatorToXML(Zotero_Creator $creator, DOMDocument $doc) { - $xmlCreator = $doc->createElement('creator'); - - $xmlCreator->setAttributeNode(new DOMAttr('libraryID', $creator->libraryID)); - $xmlCreator->setAttributeNode(new DOMAttr('key', $creator->key)); - $xmlCreator->setAttributeNode(new DOMAttr('dateAdded', $creator->dateAdded)); - $xmlCreator->setAttributeNode(new DOMAttr('dateModified', $creator->dateModified)); - - if ($creator->fieldMode == 1) { - $lastName = htmlspecialchars($creator->lastName); - - if (Zotero_Utilities::unicodeTrim($lastName) === "") { - error_log("Empty name for creator " . $creator->libraryID . "/" . $creator->key); - $lastName = json_decode('"\uFFFD"'); - } - - $xmlCreator->appendChild(new DOMElement('name', $lastName)); - $xmlCreator->appendChild(new DOMElement('fieldMode', 1)); - } - else { - $firstName = htmlspecialchars($creator->firstName); - $lastName = htmlspecialchars($creator->lastName); - - if (Zotero_Utilities::unicodeTrim($firstName) === "" && Zotero_Utilities::unicodeTrim($lastName) === "") { - error_log("Empty first or last name for creator " . $creator->libraryID . "/" . $creator->key); - $firstName = json_decode('"\uFFFD"'); - $lastName = json_decode('"\uFFFD"'); - } - - $xmlCreator->appendChild(new DOMElement('firstName', $firstName)); - $xmlCreator->appendChild(new DOMElement('lastName', $lastName)); - } - - if ($creator->birthYear) { - $xmlCreator->appendChild(new DOMElement('birthYear', $creator->birthYear)); - } - - return $xmlCreator; - } - - -/* - 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; - } -} -?> diff --git a/model/DataObject.inc.php b/model/DataObject.inc.php deleted file mode 100644 index 3966bfab..00000000 --- a/model/DataObject.inc.php +++ /dev/null @@ -1,668 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_DataObject { - protected $ObjectType; - protected $objectTypePlural; - protected $ObjectTypePlural; - protected $objectsClass; - - protected $_id; - protected $_libraryID; - protected $_key; - protected $_version; - protected $_parentID; - protected $_parentKey; - - protected $relations = []; - - // Set in DataObjects - public $inCache = false; - - protected $dataTypes = ['primaryData']; - protected $dataTypesExtended = []; - protected $identified; - protected $loaded = []; - protected $skipDataTypeLoad = []; - protected $changed = []; - protected $previousData = []; - - private $disabled = false; - - public function __construct() { - $objectType = $this->objectType; - $this->ObjectType = ucwords($objectType); - $this->objectTypePlural = \Zotero\DataObjectUtilities::getObjectTypePlural($objectType); - $this->ObjectTypePlural = ucwords($this->objectTypePlural); - $this->objectsClass = "Zotero_" . $this->ObjectTypePlural; - - $this->dataTypes = array_merge($this->dataTypes, $this->dataTypesExtended); - - $this->markAllDataTypeLoadStates(false); - $this->clearChanged(); - } - - - public function __get($field) { - if ($field == 'libraryKey') { - return $this->libraryID . "/" . $this->key; - } - - if ($field != 'id') $this->disabledCheck(); - - if (!property_exists($this, "_$field")) { - throw new Exception("Invalid property '$field'"); - } - - if (!is_null($this->{"_$field"})) { - return $this->{"_$field"}; - } - - if ($this->identified && empty($this->loaded['primaryData'])) { - $this->loadPrimaryData(); - } - return $this->{"_$field"}; - } - - - public function __set($field, $value) { - $this->disabledCheck(); - - if ($field == 'id' || $field == 'libraryID' || $field == 'key') { - return $this->setIdentifier($field, $value); - } - - if ($field == 'parentKey') { - $this->setParentKey($value); - return; - } - if ($field == 'parentID') { - $this->setParentID($value); - return; - } - - if ($this->identified) { - $this->loadPrimaryData(); - } - else { - $this->loaded['primaryData'] = true; - } - - switch ($field) { - case 'name': - if ($this->objectType == 'item') { - throw new Exception("Invalid " . $this->objectType . " property '$field'"); - } - $value = Normalizer::normalize(trim($value)); - break; - - case 'version': - $value = (int) $value; - break; - } - - $this->checkValue($field, $value); - - if ($this->{"_$field"} !== $value) { - //$this->markFieldChange(field, this['_' + field]); - if (!isset($this->changed['primaryData'])) { - $this->changed['primaryData'] = []; - } - $this->changed['primaryData'][$field] = true; - - switch ($field) { - default: - $this->{"_$field"} = $value; - } - } - } - - - public function setIdentifier($field, $value) { - switch ($field) { - case 'id': - $value = \Zotero\DataObjectUtilities::checkID($value); - if ($this->_id) { - if ($value === $this->_id) { - return; - } - throw new Exception("ID cannot be changed"); - } - if ($this->_key) { - throw new Exception("Cannot set id if key is already set"); - } - break; - - case 'libraryID': - //value = \Zotero\DataObjectUtilities\checkLibraryID(value); - break; - - case 'key': - if (is_null($this->_libraryID)) { - throw new Exception("libraryID must be set before key"); - } - $value = \Zotero\DataObjectUtilities::checkKey($value); - if ($this->_key) { - if ($value === $this->_key) { - return; - } - throw new Exception("Key cannot be changed"); - } - if ($this->_id) { - throw new Exception("Cannot set key if id is already set"); - } - } - - if ($value === $this->{"_$field"}) { - return; - } - - // If primary data is loaded, the only allowed identifier change is libraryID, and then - // only for unidentified objects, and then only if a libraryID isn't yet set (because - // primary data gets marked as loaded when fields are set for new items, but some methods - // (setCollections(), save()) automatically set the user library ID after that if none is - // specified) - if (!empty($this->loaded['primaryData'])) { - if (!(!$this->identified && $field == 'libraryID')) { - throw new Exception("Cannot change $field after object is already loaded"); - } - } - - if ($field == 'id' || $field == 'key') { - $this->identified = true; - } - - $this->{"_$field"} = $value; - } - - - /** - * Get the id of the parent object - * - * @return {Integer|false|undefined} The id of the parent object, false if none, or undefined - * on object types to which it doesn't apply (e.g., searches) - */ - public function getParentID() { - if ($this->_parentID !== null) { - return $this->_parentID; - } - if (!$this->_parentKey) { - if ($this->objectType == 'search') { - return null; - } - return false; - } - $objectsClass = $this->objectsClass; - return $this->_parentID = $objectsClass::getIDFromLibraryAndKey($this->_libraryID, $this->_parentKey); - } - - - /** - * Set the id of the parent object - * - * @param {Number|false} [id=false] - * @return {Boolean} True if changed, false if stayed the same - */ - public function setParentID($id) { - $objectsClass = $this->objectsClass; - return $this->_setParentKey( - $id - ? $objectsClass::getLibraryAndKeyFromID(\Zotero\DataObjectUtilities::checkID($id))->key - : false - ); - } - - - public function getParentKey() { - if ($this->objectType == 'search') { - return null; - } - return $this->_parentKey ? $this->_parentKey : false; - } - - /** - * Set the key of the parent object - * - * @param {String|false} [key=false] - * @return {Boolean} True if changed, false if stayed the same - */ - public function setParentKey($key) { - if ($this->objectType == 'search') { - throw new Exception("Cannot set parent key for search"); - } - - $key = \Zotero\DataObjectUtilities::checkKey($key); - if (!$key) { - $key = false; - } - - if ($key === $this->_parentKey || (!$this->_parentKey && !$key)) { - return false; - } - - Z_Core::debug("Changing parent key from '$this->_parentKey' to '$key' for " - . $this->objectType . " " . $this->libraryKey); - - //$this->_markFieldChange('parentKey', this._parentKey); - $this->changed['parentKey'] = true; - $this->_parentKey = $key; - $this->_parentID = null; - return true; - } - - - // - // Methods dealing with relations - // - /** - * Returns all relations of the object - * - * @return object Object with predicates as keys and values or arrays-of-values as values - */ - public function getRelations() { - if (!$this->loaded['relations']) { - $this->loadRelations(); - } - - $toReturn = new stdClass; - foreach ($this->relations as $relation) { - // Relations are stored internally as predicate-object pairs - $predicate = $relation[0]; - if (isset($toReturn->$predicate)) { - // If object with predicate exists, convert to an array - if (is_string($toReturn->$predicate)) { - $toReturn->$predicate = array($toReturn->$predicate); - } - // Add new object to array - $toReturn->{$predicate}[] = $relation[1]; - } - // Add first object as a string - else { - $toReturn->$predicate = $relation[1]; - } - } - return $toReturn; - } - - - /** - * Updates the object's relations - * - * @param object $newRelations Object with predicates as keys and values/arrays-of-values as values - */ - public function setRelations($newRelations) { - if (!$this->loaded['relations']) { - $this->loadRelations(); - } - - // An empty array is allowed by updateFromJSON() - if (is_array($newRelations) && empty($newRelations)) { - $newRelations = new stdClass; - } - - // There can be more than one object for a given predicate, so build - // flat arrays with individual predicate-object pairs converted to - // JSON strings so we can use array_diff to determine what changed - $oldRelations = $this->relations; - - $sortFunc = function ($a, $b) { - if ($a[0] < $b[0]) return -1; - if ($a[0] > $b[0]) return 1; - return strcmp($a[1], $b[1]); - }; - - $newRelationsFlat = []; - foreach ($newRelations as $predicate => $object) { - if (is_array($object)) { - foreach ($object as $o) { - $newRelationsFlat[] = [$predicate, $o]; - } - } - else { - $newRelationsFlat[] = [$predicate, $object]; - } - } - - $changed = false; - if (sizeOf($oldRelations) != sizeOf($newRelationsFlat)) { - $changed = true; - } - else { - usort($oldRelations, $sortFunc); - usort($newRelationsFlat, $sortFunc); - - for ($i=0; $iobjectType $this->id"); - return false; - } - - $this->storePreviousData('relations'); - // Store relations internally as array of predicate-object pairs - $this->relations = $newRelationsFlat; - $this->changed['relations'] = true; - } - - - /** - * Set the object's version to the version found in the DB. This can be set by search code - * (which should grab the version) to allow a cached copy of the object to be used. Otherwise, - * the primary data would need to be loaded just to get the version number needed to get the - * cached object.) - */ - public function setAvailableVersion($version) { - $version = (int) $version; - if ($this->loaded && $this->_version != $version) { - throw new Exception("Version does not match current value ($version != $this->_version)"); - } - $this->_version = $version; - } - - - protected function finalizeSave($env) { - if (!empty($env['id'])) { - $this->_id = $env['id']; - } - if (!empty($env['key'])) { - $this->_key = $env['key']; - } - $this->identified = true; - - $this->loadPrimaryData(true); - $this->reload(); - if ($env['isNew']) { - //Zotero_Items::cache($this); - $this->markAllDataTypeLoadStates(true); - } - $this->clearChanged(); - - $objectsClass = $this->objectsClass; - $objectsClass::registerObject($this); - } - - - /** - * Build object from database - */ - public function loadPrimaryData($reload = false, $failOnMissing = false) { - if (!$this->identified) return; - if ($this->loaded['primaryData'] && !$reload) return; - - $libraryID = $this->_libraryID; - $id = $this->_id; - $key = $this->_key; - - $objectsClass = $this->objectsClass; - - $columns = []; - $join = []; - $where = []; - - if (!$this->_id && !$this->_key) { - throw new Exception("id or key must be set to load primary data"); - } - - $primaryFields = $objectsClass::$primaryFields; - $idField = $objectsClass::$idColumn; - foreach ($primaryFields as $field) { - // If field not already set - if ($field == $idField || $this->{'_' . $field} === null || $reload) { - $columns[] = $objectsClass::getPrimaryDataSQLPart($field) . " AS `$field`"; - } - } - if (!$columns) { - return; - } - - /*if ($id) { - Z_Core::debug("Loading data for item $libraryID/$id"); - $row = Zotero_Items::getPrimaryDataByID($libraryID, $id); - } - else { - Z_Core::debug("Loading data for item $libraryID/$key"); - $row = Zotero_Items::getPrimaryDataByKey($libraryID, $key); - }*/ - - // This should match Zotero.*.primaryDataSQL, but without - // necessarily including all columns - $sql = "SELECT " . join(", ", $columns) . $objectsClass::$primaryDataSQLFrom; - if ($id) { - $sql .= " AND O.$idField=? "; - $params = $id; - } - else { - $sql .= " AND O.key=? AND O.libraryID=? "; - $params = [$key, $libraryID]; - } - $sql .= (sizeOf($where) ? ' AND ' . join(' AND ', $where) : ''); - $row = Zotero_DB::rowQuery($sql, $params, Zotero_Shards::getByLibraryID($libraryID)); - - if (!$row) { - if ($failOnMissing) { - throw new Exception( - $this->ObjectType . " " . ($id ? $id : $libraryID . "/" . $key) . " not found" - ); - } - $this->clearChanged('primaryData'); - - // If object doesn't exist, mark all data types as loaded - $this->markAllDataTypeLoadStates(true); - - return; - } - - $this->loadFromRow($row, $reload); - } - - - public function loadFromRow($row) { - foreach ($row as $key => $val) { - $field = '_' . $key; - if (!property_exists($this, $field)) { - throw new Exception($this->ObjectType . " property '$field' doesn't exist"); - } - $this->$field = $val; - } - - $this->loaded['primaryData'] = true; - $this->clearChanged('primaryData'); - $this->identified = true; - } - - - /** - * Reloads loaded, changed data - * - * @param {String[]} [dataTypes] - Data types to reload, or all loaded types if not provide - * @param {Boolean} [reloadUnchanged=false] - Reload even data that hasn't changed internally. - * This should be set to true for data that was - * changed externally (e.g., globally renamed tags). - */ - public function reload($dataTypes = null, $reloadUnchanged = false) { - if (!$this->_id) { - return; - } - - if (!$dataTypes) { - $dataTypes = array_filter(array_keys($this->loaded), function ($val) { - return $this->loaded[$val]; - }); - } - - foreach ($dataTypes as $dataType) { - if (empty($this->loaded[$dataType]) || isset($this->skipDataTypeLoad[$dataType]) - || (!$reloadUnchanged && empty($this->changed[$dataType]))) { - continue; - } - $this->loadDataType($dataType, true); - } - } - - - /** - * Checks whether a given data type has been loaded - * - * @param {String} [dataType=primaryData] Data type to check - * @throws {Zotero.DataObjects.UnloadedDataException} If not loaded, unless the - * data has not yet been "identified" - */ - protected function requireData($dataType) { - if (!isset($this->loaded[$dataType])) { - throw new Exception("$dataType is not a valid data type for $this->ObjectType objects"); - } - - if ($dataType != 'primaryData') { - $this->requireData('primaryData'); - } - - if (!$this->identified) { - $this->loaded[$dataType] = true; - } - else if (empty($this->loaded[$dataType])) { - throw new Exception( - "'$dataType' not loaded for $this->objectType (" - . $this->_id . "/" . $this->_libraryID . "/" . $this->_key . ")" - ); - } - } - - - /** - * Loads data for a given data type - * @param {String} dataType - * @param {Boolean} reload - */ - private function loadDataType($dataType, $reload = false) { - return $this->{"load" . ucwords($dataType)}($reload); - } - - - protected function markAllDataTypeLoadStates($loaded) { - foreach ($this->dataTypes as $dataType) { - $this->loaded[$dataType] = $loaded; - } - } - - public function getChanged() { - return array_keys($this->changed); - } - - - public function hasChanged() { - $changed = array_filter(array_keys($this->changed), function ($dataType) { - return $this->changed[$dataType]; - }); - foreach ($changed as $dataType) { - if ($dataType == 'primaryData' && is_array($this->changed['primaryData'])) { - foreach ($this->changed['primaryData'] as $field => $val) { - Z_Core::debug("$field has changed for item $this->libraryKey"); - } - } - else { - Z_Core::debug("$dataType has changed for item $this->libraryKey"); - } - } - return !!$changed; - } - - - /** - * Clears log of changed values - * @param {String} [dataType] data type/field to clear. Defaults to clearing everything - */ - protected function clearChanged($dataType = null) { - if ($dataType) { - unset($this->changed[$dataType]); - unset($this->previousData[$dataType]); - } - else { - $this->changed = []; - $this->previousData = []; - } - } - - - protected 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': - if (!Zotero_ID::isValidKey($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; - } - } - - - protected function storePreviousData($field) { - // Don't overwrite previous data already stored - if (isset($this->previousData[$field])) { - return; - } - $this->previousData[$field] = $this->$field; - } - - - /** - * Clears field change log - * @param {String} field - */ - private function clearFieldChange($field) { - unset($this->previousData[$field]); - } - - - private function disabledCheck() { - if ($this->disabled) { - throw new Exception("$this->ObjectType is disabled"); - } - } -} diff --git a/model/DataObjectUtilities.inc.php b/model/DataObjectUtilities.inc.php deleted file mode 100644 index b476962d..00000000 --- a/model/DataObjectUtilities.inc.php +++ /dev/null @@ -1,65 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -namespace Zotero; - -abstract class DataObjectUtilities { - public static $allowedKeyChars = "23456789ABCDEFGHIJKLMNPQRSTUVWXYZ"; - - public static function getTypeFromObject($object) { - if (!preg_match("/(Item|Collection|Search|Setting)$/", get_class($object), $matches)) { - throw new Exception("Invalid object type"); - } - return strtolower($matches[0]); - } - - - public static function getObjectTypePlural($objectType) { - if ($objectType == 'search') { - return $objectType . "es"; - } - return $objectType . "s"; - } - - - public static function checkID($dataID) { - if (!is_int($dataID) || $dataID <= 0) { - throw new Exception("id must be a positive integer"); - } - return $dataID; - } - - - public static function checkKey($key) { - if (!$key) return null; - if (!self::isValidKey($key)) throw new Exception("key is not valid"); - return $key; - } - - public static function isValidKey($key) { - return !!preg_match('/^[' . self::$allowedKeyChars . ']{8}$/', $key); - } -} diff --git a/model/DataObjects.inc.php b/model/DataObjects.inc.php deleted file mode 100644 index a26e2f42..00000000 --- a/model/DataObjects.inc.php +++ /dev/null @@ -1,795 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -trait Zotero_DataObjects { - public static $objectTypes = [ - 'item' => ['singular'=>'Item', 'plural'=>'Items'], - 'collection' => ['singular'=>'Collection', 'plural'=>'Collections'], - 'search' => ['singular'=>'Search', 'plural'=>'Searches'] - ]; - - public static $classicObjectTypes = [ - 'creator' => ['singular'=>'Creator', 'plural'=>'Creators'], - 'item' => ['singular'=>'Item', 'plural'=>'Items'], - 'collection' => ['singular'=>'Collection', 'plural'=>'Collections'], - 'search' => ['singular'=>'Search', 'plural'=>'Searches'], - 'tag' => ['singular'=>'Tag', 'plural'=>'Tags'], - 'relation' => ['singular'=>'Relation', 'plural'=>'Relations'], - 'setting' => ['singular'=>'Setting', 'plural'=>'Settings'] - ]; - - public static $idColumn; - public static $table; - public static $primaryFields; - - private static $ObjectType; - private static $objectTypePlural; - private static $ObjectTypePlural; - private static $ObjectClass; - - public static $primaryDataSQLFrom; - private static $primaryDataSQLWhere = 'WHERE 1'; - - private static $cacheVersion = 3; - - private static $objectCache = []; - private static $objectIDs = []; - private static $loadedLibraries = []; - - - public static function init() { - if (!self::$objectType) { - throw new Exception('self::$objectType must be set before calling Zotero_DataObjects initializer'); - } - - self::$ObjectType = ucwords(self::$objectType); - self::$objectTypePlural = \Zotero\DataObjectUtilities::getObjectTypePlural(self::$objectType); - self::$ObjectTypePlural = ucwords(self::$objectTypePlural); - self::$idColumn = self::$objectType . "ID"; - self::$table = isset(self::$_table) ? self::$_table : self::$objectTypePlural; - self::$ObjectClass = "Zotero_" . self::$objectType; - self::$primaryFields = array_keys(self::$primaryDataSQLParts); - self::$primaryDataSQLFrom = " " - . (isset(self::$_primaryDataSQLFrom) ? self::$_primaryDataSQLFrom : "FROM " . self::$table . " O") - . " " . self::$primaryDataSQLWhere; - } - - - protected static function getPrimaryDataSQL() { - $parts = []; - foreach (self::$primaryDataSQLParts as $key => $val) { - $parts[] = "$val AS `$key`"; - } - return "SELECT " . implode(', ', $parts) . " " . self::$primaryDataSQLFrom; - } - - - public static function getPrimaryDataSQLPart($part) { - $sql = self::$primaryDataSQLParts[$part]; - if (!isset($sql)) { - throw new Exception("Invalid primary data SQL part '$part'"); - } - return $sql; - } - - - public static function isPrimaryField($field) { - return in_array($field, self::$primaryFields); - } - - - /** - * Retrieves (and loads, if necessary) one or more items - * - * @param {Integer} libraryID - * @param {Array|Integer} ids An individual object id or an array of object ids - * @param {Object} [options] - * @param {Boolean} [options.noCache=false] - Don't add object to cache after loading - * @return {Zotero.DataObject|Zotero.DataObject[]} - Either a data object, if a scalar id was - * passed, or an array of data objects, if an array of ids was passed - */ - public static function get($libraryID, $ids, array $options = []) { - $toLoad = []; - $toReturn = []; - - if (!$ids) { - throw new Exception("No arguments provided to " . self::$ObjectTypePlural . ".get()"); - } - - if (is_array($ids)) { - $singleObject = false; - } - else { - $singleObject = true; - $ids = [$ids]; - } - - foreach ($ids as $id) { - // Check if already loaded - if (isset(self::$objectCache[$id])) { - $toReturn[] = self::$objectCache[$id]; - } - else { - $toLoad[] = $id; - } - } - - // New object to load - if ($toLoad) { - $loaded = self::load($libraryID, $toLoad, $options); - for ($i = 0; $i < sizeOf($toLoad); $i++) { - $id = $toLoad[$i]; - $obj = isset($loaded[$id]) ? $loaded[$id] : false; - if (!$obj) { - Z_Core::debug(self::$ObjectType . " $id doesn't exist", 2); - continue; - } - $toReturn[] = $obj; - } - } - - // If single id, return the object directly - if ($singleObject) { - return $toReturn ? $toReturn[0] : false; - } - - return $toReturn; - } - - - public static function getByLibraryAndKey($libraryID, $key) { - $type = self::$objectType; - $types = self::$objectTypePlural; - - $exists = self::existsByLibraryAndKey($libraryID, $key); - if (!$exists) { - return false; - } - - switch ($type) { - default: - $className = "Zotero_" . ucwords($types); - return call_user_func([$className, 'get'], $libraryID, self::$objectIDs[$libraryID][$key]); - } - } - - - public static function existsByLibraryAndKey($libraryID, $key) { - if (!$libraryID || !is_numeric($libraryID)) { - throw new Exception("libraryID '$libraryID' must be a positive integer"); - } - - $type = self::$objectType; - - if (!preg_match('/[A-Z0-9]{8}/', $key)) { - throw new Exception("Invalid key '$key'"); - } - - return !!self::getIDFromLibraryAndKey($libraryID, $key); - } - - - public static function getIDFromLibraryAndKey($libraryID, $key) { - if (isset(self::$objectIDs[$libraryID][$key])) { - return self::$objectIDs[$libraryID][$key]; - } - - $sql = "SELECT " . self::$idColumn . " FROM " . self::$table - . " WHERE libraryID=? AND `key`=?"; - $id = Zotero_DB::valueQuery( - $sql, [$libraryID, $key], Zotero_Shards::getByLibraryID($libraryID) - ); - return self::$objectIDs[$libraryID][$key] = $id; - } - - - /** - * Reload loaded data of loaded objects - * - * @param {Array|Number} ids - An id or array of ids - * @param {Array} [dataTypes] - Data types to reload (e.g., 'primaryData'), or all loaded - * types if not provided - * @param {Boolean} [reloadUnchanged=false] - Reload even data that hasn't changed internally. - * This should be set to true for data that was - * changed externally (e.g., globally renamed tags). - */ - public static function reload($ids, $dataTypes = null, $reloadUnchanged = false) { - if (is_scalar($ids)) { - $ids = [$ids]; - } - - Z_Core::debug("Reloading " . ($dataTypes ? '[' . implode(', ', dataTypes) . '] for ' : '') - . self::$objectTypePlural . ' [' . implode(', ', $ids) . ']'); - - foreach ($ids as $id) { - if (self::$objectCache[$id]) { - self::$objectCache[$id]->reload($dataTypes, $reloadUnchanged); - } - } - - return true; - } - - - public static function registerObject($obj) { - $id = $obj->id; - $libraryID = $obj->libraryID; - $key = $obj->key; - - Z_Core::debug("Registering " . self::$objectType . " " . $id - . " as " . $libraryID . "/" . $key); - if (!isset(self::$objectIDs[$libraryID])) { - self::$objectIDs[$libraryID] = []; - } - self::$objectIDs[$libraryID][$key] = $id; - self::$objectCache[$id] = $obj; - $obj->inCache = true; - } - - - /** - * Clear object from internal array - * - * @param {Integer[]} $ids - Object ids - */ - public static function unload($ids) { - if (is_scalar($ids)) { - $ids = [$ids]; - } - - foreach ($ids as $id) { - if (!isset(self::$objectCache[$id])) { - continue; - } - $obj = self::$objectCache[$id]; - $key = $obj->key; - unset(self::$objectIDs[$obj->libraryID][$obj->key]); - unset(self::$objectCache[$id]); - } - } - - - public static function updateMultipleFromJSON($json, $requestParams, $libraryID, $userID, - Zotero_Permissions $permissions, $requireVersion, $parent=null) { - $type = self::$objectType; - $types = self::$objectTypePlural; - $keyProp = $type . "Key"; - - switch ($type) { - case 'collection': - case 'search': - if ($parent) { - throw new Exception('$parent is not valid for ' . $type); - } - break; - - case 'item': - break; - - default: - throw new Exception("Function not valid for $type"); - } - - self::validateMultiObjectJSON($json, $requestParams); - - $results = new Zotero_Results($requestParams); - - if ($requestParams['v'] >= 2 && Zotero_DB::transactionInProgress()) { - throw new Exception( - "Transaction cannot be open when starting multi-object update" - ); - } - - // If single collection object, stuff in array - if ($requestParams['v'] < 2 && $type == 'collection' && !isset($json->collections)) { - $json = [$json]; - } - else if ($requestParams['v'] < 3) { - $json = $json->$types; - } - - $i = 0; - $loggableErrors = []; - foreach ($json as $prop => $jsonObject) { - Zotero_DB::beginTransaction(); - - try { - if (!is_object($jsonObject)) { - throw new Exception( - "Invalid value for index $prop in uploaded data; expected JSON $type object", - Z_ERROR_INVALID_INPUT - ); - } - - $className = "Zotero_" . ucwords($type); - $obj = new $className; - $obj->libraryID = $libraryID; - if ($type == 'item') { - $changed = self::updateFromJSON( - $obj, $jsonObject, $parent, $requestParams, $userID, $requireVersion, true - ); - } - else { - $changed = self::updateFromJSON( - $obj, $jsonObject, $requestParams, $userID, $requireVersion, true - ); - } - Zotero_DB::commit(); - - if ($changed) { - $results->addSuccessful($i, $obj->toResponseJSON($requestParams, $permissions)); - } - else { - $results->addUnchanged($i, $obj->key); - } - } - catch (Exception $e) { - Zotero_DB::rollback(); - - if ($requestParams['v'] < 2) { - throw ($e); - } - - // If object key given, include that - $resultKey = isset($jsonObject->$keyProp) - ? $jsonObject->$keyProp : ''; - - $parsed = $results->addFailure($i, $resultKey, $e); - if (!empty($parsed['log'])) { - $loggableErrors[] = $e; - } - } - $i++; - } - - if ($loggableErrors) { - $text = mb_substr(Zotero_Utilities::formatJSON($json), 0, 100000); - Z_Core::reportErrors($loggableErrors, $text); - } - - return $results->generateReport(); - } - - - protected static function validateMultiObjectJSON($json, $requestParams) { - $objectTypePlural = self::$objectTypePlural; - - if ($requestParams['v'] < 3) { - if (!is_object($json)) { - throw new Exception('Uploaded data must be a JSON object', Z_ERROR_INVALID_INPUT); - } - - // Multiple-object format - if (isset($json->$objectTypePlural)) { - if (!is_array($json->$objectTypePlural)) { - throw new Exception("'$objectTypePlural' must be an array", Z_ERROR_INVALID_INPUT); - } - foreach ($json as $key=>$val) { - if ($key != $objectTypePlural) { - throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT); - } - $maxWriteKey = "maxWrite" . ucwords($objectTypePlural); - if (sizeOf($val) > Zotero_API::$$maxWriteKey) { - throw new Exception("Cannot add more than " - . Zotero_API::$$maxWriteKey - . " $objectTypePlural at a time", Z_ERROR_UPLOAD_TOO_LARGE); - } - } - } - // Single-collection format (collections only) - else if ($requestParams['v'] < 2 && $objectTypePlural == 'collections') { - if (!isset($json->name)) { - throw new Exception("'collections' or 'name' must be provided", Z_ERROR_INVALID_INPUT); - } - } - else { - throw new Exception("'$objectTypePlural' must be provided", Z_ERROR_INVALID_INPUT); - } - - return; - } - - if (!is_array($json)) { - throw new Exception('Uploaded data must be a JSON array', Z_ERROR_INVALID_INPUT); - } - $maxWriteKey = "maxWrite" . ucwords($objectTypePlural); - if (sizeOf($json) > Zotero_API::$$maxWriteKey) { - throw new Exception("Cannot add more than " - . Zotero_API::$$maxWriteKey - . " $objectTypePlural at a time", Z_ERROR_UPLOAD_TOO_LARGE); - } - } - - - public static function countUpdated($userID, $timestamp, $deletedCheckLimit=false) { - $table = self::$table; - $id = self::$idColumn; - $type = self::$objectType; - $types = self::$objectTypePlural; - - // First, see what libraries we actually need to check - - Zotero_DB::beginTransaction(); - - // All libraries with update times >= $timestamp - $updateTimes = Zotero_Libraries::getUserLibraryUpdateTimes($userID); - $updatedLibraryIDs = array(); - foreach ($updateTimes as $libraryID=>$lastUpdated) { - if ($lastUpdated >= $timestamp) { - $updatedLibraryIDs[] = $libraryID; - } - } - - $count = self::getUpdated($userID, $timestamp, $updatedLibraryIDs, true); - - // Make sure we really have fewer than 5 - if ($deletedCheckLimit < 5) { - $count += Zotero_Sync::countDeletedObjectKeys($userID, $timestamp, $updatedLibraryIDs); - } - - Zotero_DB::commit(); - - return $count; - } - - - /** - * Returns user's object ids updated since |timestamp|, keyed by libraryID, - * or count of all updated items if $countOnly is true - * - * @param int $libraryID User ID - * @param string $timestamp Unix timestamp of last sync time - * @param array $updatedLibraryIDs Libraries with updated data - * @return array|int - */ - public static function getUpdated($userID, $timestamp, $updatedLibraryIDs, $countOnly=false) { - $table = self::$table; - $id = self::$idColumn; - $type = self::$objectType; - $types = self::$objectTypePlural; - $timestampCol = "serverDateModified"; - - // All joined groups have to be checked - $joinedGroupIDs = Zotero_Groups::getJoined($userID, $timestamp); - $joinedLibraryIDs = array(); - foreach ($joinedGroupIDs as $groupID) { - $joinedLibraryIDs[] = Zotero_Groups::getLibraryIDFromGroupID($groupID); - } - - // Separate libraries into shards for querying - $libraryIDs = array_unique(array_merge($joinedLibraryIDs, $updatedLibraryIDs)); - $shardLibraryIDs = array(); - foreach ($libraryIDs as $libraryID) { - $shardID = Zotero_Shards::getByLibraryID($libraryID); - if (!isset($shardLibraryIDs[$shardID])) { - $shardLibraryIDs[$shardID] = array( - 'updated' => array(), - 'joined' => array() - ); - } - if (in_array($libraryID, $joinedLibraryIDs)) { - $shardLibraryIDs[$shardID]['joined'][] = $libraryID; - } - else { - $shardLibraryIDs[$shardID]['updated'][] = $libraryID; - } - } - - if ($countOnly) { - $count = 0; - $fieldList = "COUNT(*)"; - } - else { - $updatedByLibraryID = array(); - $fieldList = "libraryID, $id AS id"; - } - - // Send query at each shard - foreach ($shardLibraryIDs as $shardID=>$libraryIDs) { - $sql = "SELECT $fieldList FROM $table WHERE "; - if ($libraryIDs['updated']) { - $sql .= "(libraryID IN (" . implode(', ', array_fill(0, sizeOf($libraryIDs['updated']), '?')) . ")"; - $params = $libraryIDs['updated']; - $sql .= " AND $timestampCol >= FROM_UNIXTIME(?))"; - $params[] = $timestamp; - } - - if ($libraryIDs['joined']) { - if ($libraryIDs['updated']) { - $sql .= " OR "; - } - else { - $params = array(); - } - $sql .= "libraryID IN (" . implode(', ', array_fill(0, sizeOf($libraryIDs['joined']), '?')) . ")"; - $params = array_merge($params, $libraryIDs['joined']); - } - - if ($countOnly) { - $count += Zotero_DB::valueQuery($sql, $params, $shardID, [ 'cache' => false ]); - } - else { - $rows = Zotero_DB::query($sql, $params, $shardID, [ 'cache' => false ]); - if ($rows) { - // Separate ids by libraryID - foreach ($rows as $row) { - $updatedByLibraryID[$row['libraryID']][] = $row['id']; - } - } - } - } - - return $countOnly ? $count : $updatedByLibraryID; - } - - - public static function getDeleteLogKeys($libraryID, $version, $versionIsTimestamp=false) { - // Default empty library - if ($libraryID === 0) { - return []; - } - - $type = self::$objectType; - - // TEMP: until classic syncing is deprecated and the objectType - // 'tagName' is changed to 'tag' - if ($type == 'tag') { - $type = 'tagName'; - } - - $sql = "SELECT `key` FROM syncDeleteLogKeys " - . "WHERE objectType=? AND libraryID=? AND "; - // TEMP: sync transition - $sql .= $versionIsTimestamp ? "timestamp>=FROM_UNIXTIME(?)" : "version>?"; - $keys = Zotero_DB::columnQuery( - $sql, - array($type, $libraryID, $version), - Zotero_Shards::getByLibraryID($libraryID) - ); - if (!$keys) { - return array(); - } - return $keys; - } - - - 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 delete($libraryID, $key) { - $table = self::$table; - $type = self::$objectType; - $types = self::$objectTypePlural; - - if (!$key) { - throw new Exception("Invalid key $key"); - } - - // Get object (and trigger caching) - $obj = self::getByLibraryAndKey($libraryID, $key); - if (!$obj) { - return; - } - self::editCheck($obj); - - Z_Core::debug("Deleting $type $libraryID/$key", 4); - - $shardID = Zotero_Shards::getByLibraryID($libraryID); - - Zotero_DB::beginTransaction(); - - // Delete child items - if ($type == 'item') { - if ($obj->isRegularItem()) { - $children = array_merge($obj->getNotes(), $obj->getAttachments()); - if ($children) { - $children = Zotero_Items::get($libraryID, $children); - foreach ($children as $child) { - self::delete($child->libraryID, $child->key); - } - } - } - - // Remove relations (except for merge tracker) - $uri = Zotero_URI::getItemURI($obj); - Zotero_Relations::eraseByURI( - $libraryID, $uri, array(Zotero_Relations::$deletedItemPredicate) - ); - } - // Tag deletions need to stored by tag for the API - else if ($type == 'tag') { - $tagName = $obj->name; - } - - $storageFileID = null; - if ($type == 'item' && $obj->isAttachment()) { - Zotero_FullText::deleteItemContent($obj); - - if ($obj->isImportedAttachment()) { - // Get storageFileID while it still exists in storageFileItems table - $info = Zotero_Storage::getLocalFileItemInfo($obj); - $storageFileID = $info['storageFileID']; - } - } - - try { - $sql = "DELETE FROM $table WHERE libraryID=? AND `key`=?"; - $deleted = Zotero_DB::query($sql, array($libraryID, $key), $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 ($type == 'collection' - && strpos($e->getMessage(), "Cannot delete or update a parent row") !== false) { - $deleted = self::deleteSubcollections($libraryID, $key); - } - else { - throw $e; - } - } - - self::unload($obj->id); - - if ($deleted) { - // If the attachment item was successfully deleted, check - // if other items are using this storageFileID, - // and if not, delete the row with storageFileID and libraryID from - // storageFileLibraries table - if ($storageFileID) { - Zotero_Storage::deleteFileLibraryReference($storageFileID, $libraryID); - } - - $sql = "INSERT INTO syncDeleteLogKeys - (libraryID, objectType, `key`, timestamp, version) - VALUES (?, '$type', ?, ?, ?) - ON DUPLICATE KEY UPDATE timestamp=?, version=?"; - $timestamp = Zotero_DB::getTransactionTimestamp(); - $version = Zotero_Libraries::getUpdatedVersion($libraryID); - $params = array( - $libraryID, $key, $timestamp, $version, $timestamp, $version - ); - Zotero_DB::query($sql, $params, $shardID); - - if ($type == 'tag') { - $sql = "INSERT INTO syncDeleteLogKeys - (libraryID, objectType, `key`, timestamp, version) - VALUES (?, 'tagName', ?, ?, ?) - ON DUPLICATE KEY UPDATE timestamp=?, version=?"; - $params = array( - $libraryID, $tagName, $timestamp, $version, $timestamp, $version - ); - Zotero_DB::query($sql, $params, $shardID); - } - } - - Zotero_DB::commit(); - } - - - /** - * @param SimpleXMLElement $xml Data necessary for delete as SimpleXML element - * @return void - */ - public static function deleteFromXML(SimpleXMLElement $xml, $userID) { - $parents = array(); - - foreach ($xml->children() as $obj) { - $libraryID = (int) $obj['libraryID']; - $key = (string) $obj['key']; - - if ($userID && !Zotero_Libraries::userCanEdit($libraryID, $userID)) { - throw new Exception("Cannot edit " . self::$objectType - . " in library $libraryID", Z_ERROR_LIBRARY_ACCESS_DENIED); - } - - if ($obj->getName() == 'item') { - $item = Zotero_Items::getByLibraryAndKey($libraryID, $key); - if (!$item) { - continue; - } - if (!$item->getSource()) { - $parents[] = array('libraryID' => $libraryID, 'key' => $key); - continue; - } - } - self::delete($libraryID, $key); - } - - foreach ($parents as $obj) { - self::delete($obj['libraryID'], $obj['key']); - } - } - - - private static function load($libraryID, $ids = [], array $options = []) { - $loaded = []; - - if (!$libraryID) { - throw new Exception("libraryID must be provided"); - } - - if ($libraryID !== false && !empty(self::$loadedLibraries[$libraryID])) { - return $loaded; - } - - $sql = self::getPrimaryDataSQL() . ' AND O.libraryID=?'; - $params = [$libraryID]; - if ($ids) { - $sql .= ' AND O.' . self::$idColumn . ' IN (' . implode(',', $ids) . ')'; - } - - $t = microtime(true); - $rows = Zotero_DB::query( - $sql, $params, Zotero_Shards::getByLibraryID($libraryID), [ 'cache' => false ] - ); - foreach ($rows as $row) { - $id = $row['id']; - - // Existing object -- reload in place - if (isset(self::$objectCache[$id])) { - self::$objectCache[$id]->loadFromRow($row, true); - $obj = self::$objectCache[$id]; - } - // Object doesn't exist -- create new object and stuff in cache - else { - $class = "Zotero_" . self::$ObjectType; - $obj = new $class; - $obj->loadFromRow($row, true); - if (!$options || !$options->noCache) { - self::registerObject($obj); - } - } - $loaded[$id] = $obj; - } - Z_Core::debug("Loaded " . self::$objectTypePlural . " in " . (microtime(true) - $t) . "ms"); - - if (!$ids) { - self::$loadedLibraries[$libraryID] = true; - - // If loading all objects, remove cached objects that no longer exist - foreach (self::$objectCache as $obj) { - if ($libraryID !== false && obj.libraryID !== libraryID) { - continue; - } - if (empty($loaded[$obj->id])) { - self::unload($obj->id); - } - } - } - - return $loaded; - } -} diff --git a/model/Date.inc.php b/model/Date.inc.php deleted file mode 100644 index 41c1af8d..00000000 --- a/model/Date.inc.php +++ /dev/null @@ -1,362 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_Date { - private static $locale = 'en-US'; - - private static function getString($key) { - switch ($key) { - case 'date.yesterday': - return "yesterday"; - - case 'date.today': - return "today"; - - case 'date.tomorrow': - return "tomorrow"; - - case 'date.daySuffixes': - return "st, nd, rd, th"; - } - } - - /* - * converts a string to an object containing: - * day: integer form of the day - * month: integer form of the month - * year: 4 digit year (or, year + BC/AD/etc.) - * part: anything that does not fall under any of the above categories - * (e.g., "Summer," etc.) - */ - private static $slashRE = "/^(.*?)\b([0-9]{1,4})(?:([\-\/\.年])([0-9]{1,2}))?(?:([\-\/\.月])([0-9]{1,4}))?((?:\b|[^0-9]).*?)$/u"; - private static $yearRE = "/^(.*?)\b((?:circa |around |about |c\.? ?)?[0-9]{1,4}(?: ?B\.? ?C\.?(?: ?E\.?)?| ?C\.? ?E\.?| ?A\.? ?D\.?)|[0-9]{3,4})\b(.*?)$/iu"; - private static $monthRE = null; - private static $dayRE = null; - - public static function strToDate($string) { - // Parse 'yesterday'/'today'/'tomorrow' - $lc = strtolower($string); - if ($lc == 'yesterday' || $lc == self::getString('date.yesterday')) { - $string = date("Y-m-d", strtotime('yesterday')); - } - else if ($lc == 'today' || $lc == self::getString('date.today')) { - $string = date("Y-m-d"); - } - else if ($lc == 'tomorrow' || $lc == self::getString('date.tomorrow')) { - $string = date("Y-m-d", strtotime('tomorrow')); - } - - $date = array(); - - // skip empty things - if (!$string) { - return $date; - } - - $string = preg_replace( - array("/^\s+/", "/\s+$/", "/\s+/"), - array("", "", " "), - $string - ); - - // first, directly inspect the string - preg_match(self::$slashRE, $string, $m); - if ($m && - (empty($m[5]) || $m[3] == $m[5] || ($m[3] == "年" && $m[5] == "月")) && // require sane separators - ((!empty($m[2]) && !empty($m[4]) && !empty($m[6])) || (empty($m[1]) && empty($m[7])))) { // require that either all parts are found, - // or else this is the entire date field - // figure out date based on parts - if (mb_strlen($m[2]) == 3 || mb_strlen($m[2]) == 4 || $m[3] == "年") { - // ISO 8601 style date (big endian) - $date['year'] = $m[2]; - $date['month'] = $m[4]; - $date['day'] = $m[6]; - } - else { - // local style date (middle or little endian) - $date['year'] = $m[6]; - $country = substr(self::$locale, 3); - if ($country == "US" || // The United States - $country == "FM" || // The Federated States of Micronesia - $country == "PW" || // Palau - $country == "PH") { // The Philippines - $date['month'] = $m[2]; - $date['day'] = $m[4]; - } - else { - $date['month'] = $m[4]; - $date['day'] = $m[2]; - } - } - - if ($date['year']) $date['year'] = (int) $date['year']; - if ($date['day']) $date['day'] = (int) $date['day']; - if ($date['month']) { - $date['month'] = (int) $date['month']; - - if ($date['month'] > 12) { - // swap day and month - $tmp = $date['day']; - $date['day'] = $date['month']; - $date['month'] = $tmp; - } - } - - if ((empty($date['month']) || $date['month'] <= 12) && (empty($date['day']) || $date['day'] <= 31)) { - if (!empty($date['year']) && $date['year'] < 100) { // for two digit years, determine proper - $year = date('Y'); - $twoDigitYear = date('y'); - $century = $year - $twoDigitYear; - - if ($date['year'] <= $twoDigitYear) { - // assume this date is from our century - $date['year'] = $century + $date['year']; - } - else { - // assume this date is from the previous century - $date['year'] = $century - 100 + $date['year']; - } - } - - Z_Core::debug("DATE: retrieved with algorithms: " . json_encode($date)); - - $date['part'] = $m[1] . $m[7]; - } - else { - // give up; we failed the sanity check - Z_Core::debug("DATE: algorithms failed sanity check"); - $date = array("part" => $string); - } - } - else { - //Zotero.debug("DATE: could not apply algorithms"); - $date['part'] = $string; - } - - // couldn't find something with the algorithms; use regexp - // YEAR - if (empty($date['year'])) { - if (preg_match(self::$yearRE, $date['part'], $m)) { - $date['year'] = $m[2]; - $date['part'] = $m[1] . $m[3]; - Z_Core::debug("DATE: got year (" . $date['year'] . ", " . $date['part'] . ")"); - } - } - - // MONTH - if (empty($date['month'])) { - // compile month regular expression - $months = array('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', - 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'); - // If using a non-English bibliography locale, try those too - if (self::$locale != 'en-US') { - throw new Exception("Unimplemented"); - //$months = array_merge($months, .concat(Zotero.$date['month']s.short); - } - if (!self::$monthRE) { - self::$monthRE = "/^(.*)\\b(" . implode("|", $months) . ")[^ ]*(?: (.*)$|$)/iu"; - } - - if (preg_match(self::$monthRE, $date['part'], $m)) { - // Modulo 12 in case we have multiple languages - $date['month'] = (array_search(ucwords(strtolower($m[2])), $months) % 12) + 1; - $date['part'] = $m[1] . (isset($m[3]) ? $m[3] : ''); - Z_Core::debug("DATE: got month (" . $date['month'] . ", " . $date['part'] . ")"); - } - } - - // DAY - if (empty($date['day'])) { - // compile day regular expression - if(!self::$dayRE) { - $daySuffixes = preg_replace("/, ?/", "|", self::getString("date.daySuffixes")); - self::$dayRE = "/\\b([0-9]{1,2})(?:" . $daySuffixes . ")?\\b(.*)/iu"; - } - - if (preg_match(self::$dayRE, $date['part'], $m, PREG_OFFSET_CAPTURE)) { - $day = (int) $m[1][0]; - // Sanity check - if ($day <= 31) { - $date['day'] = $day; - if ($m[0][1] > 0) { - $date['part'] = substr($date['part'], 0, $m[0][1]); - if ($m[2][0]) { - $date['part'] .= " " . $m[2][0]; - } - } - else { - $date['part'] = $m[2][0]; - } - - Z_Core::debug("DATE: got day (" . $date['day'] . ", " . $date['part'] . ")"); - } - } - } - - // clean up date part - if ($date['part']) { - $date['part'] = preg_replace( - array("/^[^A-Za-z0-9]+/", "/[^A-Za-z0-9]+$/"), - "", - $date['part'] - ); - } - - if ($date['part'] === "" || !isset($date['part'])) { - unset($date['part']); - } - - return $date; - } - - - // Regexes for multipart and SQL dates - // Allow zeroes in multipart dates - // TODO: Allow negative multipart in DB and here with \-? - private static $multipartRE = "/^[0-9]{4}\-(0[0-9]|10|11|12)\-(0[0-9]|[1-2][0-9]|30|31) /"; - private static $sqldateRE = "/^\-?[0-9]{4}\-(0[1-9]|10|11|12)\-(0[1-9]|[1-2][0-9]|30|31)$/"; - private static $sqldatetimeRE = "/^\-?[0-9]{4}\-(0[1-9]|10|11|12)\-(0[1-9]|[1-2][0-9]|30|31) ([0-1][0-9]|[2][0-3]):([0-5][0-9]):([0-5][0-9])$/"; - - /** - * Tests if a string is a multipart date string - * e.g. '2006-11-03 November 3rd, 2006' - */ - public static function isMultipart($str) { - $isMultipart = !!preg_match(self::$multipartRE, $str); - // Make sure the year actually appears after YYYY-MM-DD and this isn't just an SQL date - if ($isMultipart && substr($str, 0, 4) != '0000') { - $isMultipart = strpos(substr($str, 11), substr($str, 0, 4)) !== false; - } - return $isMultipart; - } - - - /** - * Returns the SQL part of a multipart date string - * (e.g. '2006-11-03 November 3rd, 2006' returns '2006-11-03') - */ - public static function multipartToSQL($multi) { - if (!$multi) { - return ''; - } - - if (!self::isMultipart($multi)) { - return '0000-00-00'; - } - - return substr($multi, 0, 10); - } - - - /** - * Returns the user part of a multipart date string - * (e.g. '2006-11-03 November 3rd, 2006' returns 'November 3rd, 2006') - */ - public static function multipartToStr($multi) { - if (!$multi) { - return ''; - } - - if (!self::isMultipart($multi)) { - return $multi; - } - - return substr($multi, 11); - } - - - public static function strToMultipart($str){ - if (!$str) { - return ''; - } - - $parts = self::strToDate($str); - - // FIXME: Until we have a better BCE date solution, - // remove year value if not between 1 and 9999 - if (isset($parts['year'])) { - if (!preg_match("/^[0-9]{1,4}$/", $parts['year'])) { - unset($parts['year']); - } - } - - $multi = (!empty($parts['year']) ? str_pad($parts['year'], 4, '0', STR_PAD_LEFT) : '0000') . '-' - . ((!empty($parts['month']) && $parts['month'] <= 12) ? str_pad($parts['month'], 2, '0', STR_PAD_LEFT) : '00') . '-' - . ((!empty($parts['day']) && $parts['day'] <= 31) ? str_pad($parts['day'], 2, '0', STR_PAD_LEFT) : '00') - . ' ' - . $str; - return $multi; - } - - - public static function isSQLDate($str) { - return !!preg_match(self::$sqldateRE, $str); - } - - - public static function isSQLDateTime($str) { - return !!preg_match(self::$sqldatetimeRE, $str); - } - - - public static function isISO8601($str) { - return !!DateTime::createFromFormat(DateTime::ISO8601, $str); - } - - - public static function sqlToISO8601($sqlDate) { - $date = substr($sqlDate, 0, 10); - preg_match('/^([0-9]{4})\-([0-9]{2})\-([0-9]{2})/', $sqlDate, $matches); - if (!$matches) { - return false; - } - $date = $matches[1]; - // Drop parts for reduced precision - if ($matches[2] !== "00") { - $date .= "-" . $matches[2]; - if ($matches[3] !== "00") { - $date .= "-" . $matches[3]; - } - } - $time = substr($sqlDate, 11); - // TODO: validate times - if ($time) { - $date .= "T" . $time . "Z"; - } - return $date; - } - - - public static function iso8601ToSQL($isoDate) { - $date = DateTime::createFromFormat(DateTime::ISO8601, $isoDate); - if (!$date) { - throw new Exception("'$isoDate' is not an ISO 8601 date"); - } - $date->setTimezone(new DateTimeZone('UTC')); - return $date->format('Y-m-d H:i:s'); - } -} -?> diff --git a/model/Error.inc.php b/model/Error.inc.php deleted file mode 100644 index f3cecb2b..00000000 --- a/model/Error.inc.php +++ /dev/null @@ -1,31 +0,0 @@ - diff --git a/model/Errors.inc.php b/model/Errors.inc.php deleted file mode 100644 index 7caf680a..00000000 --- a/model/Errors.inc.php +++ /dev/null @@ -1,174 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_Errors { - /** - * Extract from exception an error message, HTTP response code, and whether - * the exception should be logged - */ - public static function parseException(Throwable $e) { - $error = array( - 'exception' => $e - ); - - $msg = $e->getMessage(); - if ($msg[0] == '=') { - $msg = substr($msg, 1); - $explicit = true; - } - else { - $explicit = false; - } - $error['message'] = $msg; - - $errorCode = $e->getCode(); - switch ($errorCode) { - case Z_ERROR_INVALID_INPUT: - case Z_ERROR_CITESERVER_INVALID_STYLE: - $error['code'] = 400; - $error['log'] = true; - break; - - case Z_ERROR_LIBRARY_ACCESS_DENIED: - $error['code'] = 403; - $error['log'] = true; - break; - - case Z_ERROR_FIELD_TOO_LONG: - $error['code'] = 413; - preg_match("/([A-Za-z ]+) field value '(.+)' too long/", $msg, $matches); - if ($matches) { - $name = $matches[1]; - $value = $matches[2]; - $error['data']['field'] = $name; - $error['data']['value'] = $value; - } - break; - - case Z_ERROR_CREATOR_TOO_LONG: - $error['code'] = 413; - preg_match("/Creator value '(.+)' too long/", $msg, $matches); - if ($matches) { - $name = $matches[1]; - // TODO: Replace with simpler message after client version 5.0.36, - // which includes this locally - $error['message'] = "The creator name ‘{$name}…’ is too long to sync. " - . "Shorten the name and sync again.\n\n" - . "If you receive this message repeatedly for items saved from a " - . "particular site, you can report this issue in the Zotero Forums."; - $error['data']['field'] = 'creator'; - $error['data']['value'] = $name; - } - break; - - case Z_ERROR_COLLECTION_TOO_LONG: - $error['code'] = 413; - preg_match("/Collection name '(.+)' too long/", $msg, $matches); - if ($matches) { - $error['data']['value'] = $matches[1]; - } - break; - - case Z_ERROR_NOTE_TOO_LONG: - $error['code'] = 413; - break; - - case Z_ERROR_TAG_TOO_LONG: - $error['code'] = 413; - preg_match("/Tag '(.+)' too long/s", $msg, $matches); - if ($matches) { - $name = $matches[1]; - $error['message'] = "Tag '" . mb_substr($name, 0, 50) . "…' too long"; - $error['data']['tag'] = $name; - } - break; - - case Z_ERROR_COLLECTION_NOT_FOUND: - case Z_ERROR_ITEM_NOT_FOUND: - case Z_ERROR_TAG_NOT_FOUND: - if ($errorCode == Z_ERROR_COLLECTION_NOT_FOUND) { - preg_match("/Collection \d+\/([^ ]+) doesn't exist/", $msg, $matches); - if ($matches) { - $error['code'] = 409; - $error['message'] = "Collection {$matches[1]} not found"; - $error['data']['collection'] = $matches[1]; - } - else { - preg_match("/Parent collection \d+\/([^ ]+) doesn't exist/", $msg, $matches); - if ($matches) { - $error['code'] = 409; - $error['message'] = "Parent collection {$matches[1]} not found"; - $error['data']['collection'] = $matches[1]; - } - } - } - else if ($errorCode == Z_ERROR_ITEM_NOT_FOUND) { - preg_match("/Parent item \d+\/([^ ]+) doesn't exist/", $msg, $matches); - if ($matches) { - $error['code'] = 409; - $error['message'] = "Parent item {$matches[1]} not found"; - $error['data']['parentItem'] = $matches[1]; - } - } - if (!isset($error['code'])) { - // TODO: Change to 409 - $error['code'] = 400; - } - $error['log'] = true; - break; - - case Z_ERROR_UPLOAD_TOO_LARGE: - $error['code'] = 413; - $error['log'] = true; - break; - - case Z_ERROR_SHARD_READ_ONLY: - case Z_ERROR_SHARD_UNAVAILABLE: - $error['code'] = 503; - $error['message'] = Z_CONFIG::$MAINTENANCE_MESSAGE; - $error['log'] = true; - break; - - default: - if (!($e instanceof HTTPException) || $errorCode == 500) { - $error['code'] = 500; - if (Z_ENV_TESTING_SITE) { - $error['message'] = $e; - } - else { - $error['message'] = "An error occurred"; - } - } - $error['log'] = true; - } - - if ($e instanceof HTTPException) { - $error['code'] = $e->getCode(); - } - - return $error; - } -} diff --git a/model/GlobalItems.inc.php b/model/GlobalItems.inc.php deleted file mode 100644 index 0dfddcea..00000000 --- a/model/GlobalItems.inc.php +++ /dev/null @@ -1,133 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_GlobalItems { - const endpointTimeout = 3; - - public static function getGlobalItems($params) { - $requestURL = Z_CONFIG::$GLOBAL_ITEMS_URL; - if ($requestURL[strlen($requestURL) - 1] != "/") { - $requestURL .= "/"; - } - $requestURL .= 'global/items'; - - // If a single-object query - if (!empty($params['id'])) { - $requestURL .= '/' . rawurlencode($params['id']); - } - // If a multi-object query - else { - if (!empty($params['q'])) { - $requestURL .= '?q=' . rawurlencode($params['q']); - } - else if (!empty($params['doi'])) { - $requestURL .= '?doi=' . rawurlencode($params['doi']); - } - else if (!empty($params['isbn'])) { - $requestURL .= '?isbn=' . rawurlencode($params['isbn']); - } - else { - throw new Exception("Missing query parameter"); - } - - if (!empty($params['start'])) { - $requestURL .= '&start=' . $params['start']; - } - - if (!empty($params['limit'])) { - $requestURL .= '&limit=' . $params['limit']; - } - } - - $start = microtime(true); - - $ch = curl_init($requestURL); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1); - curl_setopt($ch, CURLOPT_TIMEOUT, self::endpointTimeout); - curl_setopt($ch, CURLOPT_HEADER, 1); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - // Allow an invalid ssl certificate (Todo: remove) - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0); - $response = curl_exec($ch); - - $time = microtime(true) - $start; - StatsD::timing("api.globalitems", $time * 1000); - - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - // If a single item request - if ($code == 404 && !empty($params['id'])) { - return false; - } - - if ($code != 200) { - throw new Exception($code . " from global items server " - . "[URL: '$requestURL'] [RESPONSE: '$response']"); - } - - $headerSize = strpos($response, "\r\n\r\n") + 4; - $body = substr($response, $headerSize); - $headerLines = explode("\r\n", trim(substr($response, 0, $headerSize))); - unset($headerLines[0]); - $headers = []; - foreach ($headerLines as $headerLine) { - list($key, $val) = explode(':', $headerLine, 2); - $headers[strtolower($key)] = trim($val); - } - - $json = json_decode($body, true); - return [ - 'totalResults' => $headers['total-results'], - 'data' => $json - ]; - } - - public static function getGlobalItemLibraryItems($id) { - $params = [ - 'id' => $id - ]; - $result = self::getGlobalItems($params); - if (!$result) return false; - $libraryItems = $result['data']['libraryItems']; - sort($libraryItems); - $parsedLibraryItems = []; - for ($i = 0, $len = sizeOf($libraryItems); $i < $len; $i++) { - list($libraryID, $key) = explode('/', $libraryItems[$i]); - $parsedLibraryItems[] = [$libraryID, $key]; - } - return $parsedLibraryItems; - } - - public static function getGlobalItemDatesAdded($id) { - $params = [ - 'id' => $id - ]; - $result = self::getGlobalItems($params); - if (!$result) return false; - return $result['data']['meta']['datesAdded']; - } -} diff --git a/model/Group.inc.php b/model/Group.inc.php deleted file mode 100644 index faf077f0..00000000 --- a/model/Group.inc.php +++ /dev/null @@ -1,1256 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_Group { - private $id; - private $libraryID; - private $ownerUserID; - private $name; - private $type; - private $libraryEditing; - private $libraryReading; - private $fileEditing; - private $description = ""; - private $url = ""; - private $hasImage = false; - private $dateAdded; - private $dateModified; - private $version; - - private $loaded = false; - private $changed = array(); - private $erased = false; - private $userData; - - - public function __get($field) { - if ($this->erased && $field != 'erased') { - throw new Exception("Cannot access field '$field' of deleted group $this->id"); - } - - if (($this->id || $this->libraryID) && !$this->loaded) { - $this->load(); - } - - switch ($field) { - case 'id': - case 'libraryID': - case 'name': - case 'type': - case 'ownerUserID': - case 'libraryEditing': - case 'libraryReading': - case 'fileEditing': - case 'description': - case 'url': - case 'hasImage': - case 'dateAdded': - case 'dateModified': - case 'version': - case 'erased': - break; - - case 'slug': - if ($this->isPublic()) { - return Zotero_Utilities::slugify($this->name); - } - return null; - - case 'etag': - return $this->getETag(); - - default: - throw new Exception("Invalid group field '$field'"); - } - - return $this->$field; - } - - - public function __set($field, $value) { - switch ($field) { - // Set id and libraryID without loading - case 'id': - case 'libraryID': - if ($this->loaded) { - throw new Exception("Cannot set $field after group is already loaded"); - } - $this->$field = $value; - return; - - case 'name': - case 'description': - case 'ownerUserID': - break; - - case 'hasImage': - if (!is_bool($value)) { - throw new Exception("hasImage must be a bool (was " . gettype($value) . ")"); - } - break; - - case 'type': - switch ($value) { - case "PublicOpen": - case "PublicClosed": - case "Private": - break; - - default: - throw new Exception("Invalid group type '$value'"); - } - break; - - case 'libraryEditing': - case 'fileEditing': - if ($field == 'fileEditing') { - if (!$this->type) { - throw new Exception("Group type must be set before fileEditing"); - } - - if ($value != 'none' && $this->type == 'PublicOpen') { - throw new Exception("fileEditing cannot be enabled for PublicOpen group"); - } - } - - switch ($value) { - case "admins": - case "members": - case "none": - break; - - default: - throw new Exception("Invalid $field value '$value'"); - } - break; - - case 'libraryReading': - switch ($value) { - case "members": - case "all": - break; - - default: - throw new Exception("Invalid $field value '$value'"); - } - break; - - case 'url': - // TODO: validate URL - break; - - default: - throw new Exception("Invalid group field '$field'"); - } - - if ($this->id || $this->libraryID) { - if (!$this->loaded) { - $this->load(); - } - } - else { - $this->loaded = true; - } - - if ($this->$field == $value) { - Z_Core::debug("Group $this->id $field value ($value) has not changed", 4); - return; - } - $this->$field = $value; - $this->changed[$field] = true; - } - - - public function exists() { - return $this->__get('id') && $this->__get('libraryID'); - } - - - public function isPublic() { - return $this->type == 'PublicOpen' || $this->type == 'PublicClosed'; - } - - - public function hasUser($userID) { - if (!$this->exists()) { - return array(); - } - - $sql = "SELECT COUNT(*) FROM groupUsers WHERE groupID=? AND userID=?"; - return !!Zotero_DB::valueQuery($sql, array($this->id, $userID)); - } - - - public function getUsers() { - if (!$this->exists()) { - return array(); - } - - $sql = "SELECT userID FROM groupUsers WHERE groupID=?"; - $ids = Zotero_DB::columnQuery($sql, $this->id); - if (!$ids) { - // Shouldn't happen - throw new Exception("Group has no users"); - } - return $ids; - } - - - /** - * Returns group admins - * - * @return {Integer[]} Array of userIDs - */ - public function getAdmins() { - if (!$this->exists()) { - return array(); - } - - $sql = "SELECT userID FROM groupUsers WHERE groupID=? AND role='admin'"; - $ids = Zotero_DB::columnQuery($sql, $this->id); - if (!$ids) { - return array(); - } - return $ids; - } - - - /** - * Returns group members - * - * @return {Integer[]} Array of userIDs - */ - public function getMembers() { - if (!$this->exists()) { - return array(); - } - - $sql = "SELECT userID FROM groupUsers WHERE groupID=? AND role='member'"; - $ids = Zotero_DB::columnQuery($sql, $this->id); - if (!$ids) { - return array(); - } - - $ids = Zotero_Users::getValidUsers($ids); - - return $ids; - } - - - public function getUserData($userID) { - if (!$this->exists()) { - throw new Exception("Group hasn't been saved"); - } - - if (isset($this->userData[$userID])) { - return $this->userData[$userID]; - } - - $sql = "SELECT role, joined, lastUpdated FROM groupUsers WHERE groupID=? AND userID=?"; - $row = Zotero_DB::rowQuery($sql, array($this->id, $userID)); - return $this->userData[$userID] = $row; - } - - - public function getUserRole($userID) { - $data = $this->getUserData($userID); - if (!$data) { - return false; - } - return $data['role']; - } - - - public function addUser($userID, $role='member') { - if (!$this->exists()) { - throw new Exception("Group hasn't been saved"); - } - - switch ($role) { - case 'admin': - case 'member': - break; - - default: - throw new Exception("Invalid role '$role' adding user $userID to group $this->id"); - } - - Zotero_DB::beginTransaction(); - - if (!Zotero_Users::exists($userID)) { - Zotero_Users::addFromWWW($userID); - } - - $sql = "INSERT IGNORE INTO groupUsers (groupID, userID, role, joined) - VALUES (?, ?, ?, CURRENT_TIMESTAMP)"; - $added = Zotero_DB::query($sql, array($this->id, $userID, $role)); - - $sql = "UPDATE groups SET dateModified=CURRENT_TIMESTAMP, version=version+1 WHERE groupID=?"; - Zotero_DB::query($sql, $this->id); - - // Clear cache - unset($this->userData[$userID]); - - // Delete any record of this user losing access to the group - if ($added) { - $libraryID = Zotero_Users::getLibraryIDFromUserID($userID); - $sql = "DELETE FROM syncDeleteLogIDs WHERE libraryID=? AND objectType='group' AND id=?"; - Zotero_DB::query($sql, array($libraryID, $this->id), Zotero_Shards::getByLibraryID($libraryID)); - } - - // Send library add notification for all user's API keys with access to this group - $apiKeys = Zotero_Keys::getUserKeysWithLibrary($userID, $this->libraryID); - Zotero_Notifier::trigger('add', 'apikey-library', array_map(function ($key) { - return $key->id . "-" . $this->libraryID; - }, $apiKeys)); - - // If group is locked by a sync, flag for later timestamp update - // once the sync is done so that the uploading user gets the change - try { - if ($syncUploadQueueID = Zotero_Sync::getUploadQueueIDByUserID($userID)) { - Zotero_Sync::postWriteLog($syncUploadQueueID, 'groupUser', $this->id . '-' . $userID, 'update'); - } - } - catch (Exception $e) { - Z_Core::logError($e); - } - - Zotero_DB::commit(); - - return $added; - } - - - public function updateUser($userID, $role='member') { - if (!$this->exists()) { - throw new Exception("Group hasn't been saved"); - } - - switch ($role) { - case 'admin': - case 'member': - break; - - default: - throw new Exception("Invalid role '$role' updating user $userID in group $this->id"); - } - - Zotero_DB::beginTransaction(); - - $oldRole = $this->getUserRole($userID); - if ($oldRole == $role) { - Z_Core::debug("Role hasn't changed for user $userID in group $this->id"); - Zotero_DB::commit(); - return; - } - if ($oldRole == 'owner') { - throw new Exception("Cannot change group owner to $role for group $this->id", Z_ERROR_CANNOT_DELETE_GROUP_OWNER); - } - - $sql = "UPDATE groupUsers SET role=?, lastUpdated=CURRENT_TIMESTAMP - WHERE groupID=? AND userID=?"; - $updated = Zotero_DB::query($sql, array($role, $this->id, $userID)); - - $sql = "UPDATE groups SET dateModified=CURRENT_TIMESTAMP, version=version+1 WHERE groupID=?"; - Zotero_DB::query($sql, $this->id); - - // Clear cache - unset($this->userData[$userID]); - - // If group is locked by a sync, flag for later timestamp update - // once the sync is done so that the uploading user gets the change - try { - if ($syncUploadQueueID = Zotero_Sync::getUploadQueueIDByUserID($userID)) { - Zotero_Sync::postWriteLog($syncUploadQueueID, 'groupUser', $this->id . '-' . $userID, 'update'); - } - } - catch (Exception $e) { - Z_Core::logError($e); - } - - Zotero_DB::commit(); - - return $updated; - } - - - public function removeUser($userID) { - if (!$this->exists()) { - throw new Exception("Group hasn't been saved"); - } - - Zotero_DB::beginTransaction(); - - if (!$this->hasUser($userID)) { - throw new Exception("User $userID is not a member of group $this->id", Z_ERROR_USER_NOT_GROUP_MEMBER); - } - - $role = $this->getUserRole($userID); - if ($role == 'owner') { - throw new Exception("Cannot delete owner of group $this->id", Z_ERROR_CANNOT_DELETE_GROUP_OWNER); - } - - // Remove group from permissions the user has granted - $sql = "DELETE KP FROM keyPermissions KP JOIN `keys` USING (keyID) WHERE userID=? AND libraryID=?"; - Zotero_DB::query($sql, array($userID, $this->libraryID)); - - $sql = "DELETE FROM groupUsers WHERE groupID=? AND userID=?"; - Zotero_DB::query($sql, array($this->id, $userID)); - - $sql = "UPDATE groups SET dateModified=CURRENT_TIMESTAMP, version=version+1 WHERE groupID=?"; - Zotero_DB::query($sql, $this->id); - - // Clear cache - unset($this->userData[$userID]); - - // A group user removal is logged as a deletion of the group from the user's personal library - $sql = "REPLACE INTO syncDeleteLogIDs (libraryID, objectType, id) VALUES (?, 'group', ?)"; - $libraryID = Zotero_Users::getLibraryIDFromUserID($userID); - Zotero_DB::query($sql, array($libraryID, $this->id), Zotero_Shards::getByLibraryID($libraryID)); - - // Send library removal notification for all user's API keys with access to this group - $apiKeys = Zotero_Keys::getUserKeysWithLibrary($userID, $this->libraryID); - Zotero_Notifier::trigger('remove', 'apikey-library', array_map(function ($key) { - return $key->id . "-" . $this->libraryID; - }, $apiKeys)); - - // If group is locked by a sync, flag for later timestamp update - // once the sync is done so that the uploading user gets the change - try { - if ($syncUploadQueueID = Zotero_Sync::getUploadQueueIDByUserID($userID)) { - Zotero_Sync::postWriteLog($syncUploadQueueID, 'groupUser', $this->id . '-' . $userID, 'delete'); - } - } - catch (Exception $e) { - Z_Core::logError($e); - } - - Zotero_DB::commit(); - } - - - public function userCanRead($userID) { - if (($this->id || $this->libraryID) && !$this->loaded) { - $this->load(); - } - - // All members can read - $role = $this->getUserRole($userID); - if ($role) { - return true; - } - - return $this->isPublic() && $this->libraryReading == 'all'; - } - - - public function userCanEdit($userID) { - if (($this->id || $this->libraryID) && !$this->loaded) { - $this->load(); - } - - $role = $this->getUserRole($userID); - switch ($role) { - case 'owner': - case 'admin': - return true; - - case 'member': - if ($this->libraryEditing == 'members') { - return true; - } - } - return false; - } - - - public function userCanEditFiles($userID) { - if (($this->id || $this->libraryID) && !$this->loaded) { - $this->load(); - } - - if ($this->fileEditing == 'none') { - return false; - } - - $role = $this->getUserRole($userID); - switch ($role) { - case 'owner': - case 'admin': - return true; - - case 'member': - if ($this->fileEditing == 'members') { - return true; - } - } - return false; - } - - - /** - * Returns group items - * - * @return {Integer[]} Array of itemIDs - */ - public function getItems($asIDs=false) { - $sql = "SELECT itemID FROM items WHERE libraryID=?"; - $ids = Zotero_DB::columnQuery($sql, $this->libraryID, Zotero_Shards::getByLibraryID($this->libraryID)); - if (!$ids) { - return array(); - } - - if ($asIDs) { - return $ids; - } - - return Zotero_Items::get($this->libraryID, $ids); - } - - - /** - * Returns the number of items in the group - */ - public function numItems() { - if (!$this->loaded) { - $this->load(); - } - - $sql = "SELECT COUNT(*) FROM items WHERE libraryID=?"; - return Zotero_DB::valueQuery($sql, $this->libraryID, Zotero_Shards::getByLibraryID($this->libraryID)); - } - - - public function save() { - if (!$this->loaded) { - Z_Core::debug("Not saving unloaded group $this->id"); - return; - } - - if (empty($this->changed)) { - Z_Core::debug("Group $this->id has not changed", 4); - return; - } - - if (!$this->ownerUserID) { - throw new Exception("Cannot save group without owner"); - } - - if (!$this->name) { - throw new Exception("Cannot save group without name"); - } - - if (mb_strlen($this->description) > 1024) { - throw new Exception("Group description too long", Z_ERROR_GROUP_DESCRIPTION_TOO_LONG); - } - - Zotero_DB::beginTransaction(); - - $libraryID = $this->libraryID; - if (!$libraryID) { - $shardID = Zotero_Shards::getNextShard(); - $libraryID = Zotero_Libraries::add('group', $shardID); - if (!$libraryID) { - throw new Exception('libraryID not available after Zotero_Libraries::add()'); - } - } - - $fields = array( - 'name', - 'slug', - 'type', - 'description', - 'url', - 'hasImage' - ); - - if ($this->isPublic()) { - $existing = Zotero_Groups::publicNameExists($this->name); - if ($existing && $existing != $this->id) { - throw new Exception("Public group with name '$this->name' already exists", Z_ERROR_PUBLIC_GROUP_EXISTS); - } - } - - $fields = array_merge($fields, array('libraryEditing', 'libraryReading', 'fileEditing')); - - $sql = "INSERT INTO groups - (groupID, libraryID, " . implode(", ", $fields) . ", dateModified) - VALUES (?, ?, " . implode(", ", array_fill(0, sizeOf($fields), "?")) . ", CURRENT_TIMESTAMP)"; - $params = array($this->id, $libraryID); - foreach ($fields as $field) { - if (is_bool($this->$field)) { - $params[] = (int) $this->$field; - } - else { - $params[] = $this->$field; - } - } - $sql .= " ON DUPLICATE KEY UPDATE "; - $q = array(); - foreach ($fields as $field) { - $q[] = "$field=?"; - if (is_bool($this->$field)) { - $params[] = (int) $this->$field; - } - else { - $params[] = $this->$field; - } - } - $sql .= implode(", ", $q) . ", dateModified=CURRENT_TIMESTAMP, version=version+1"; - $insertID = Zotero_DB::query($sql, $params); - - if (!$this->id) { - if (!$insertID) { - throw new Exception("Group id not available after INSERT"); - } - $this->id = $insertID; - } - - if (!$this->libraryID) { - $this->libraryID = $libraryID; - } - - // If creating group or changing owner - if (!empty($this->changed['ownerUserID'])) { - $sql = "SELECT userID FROM groupUsers WHERE groupID=? AND role='owner'"; - $currentOwner = Zotero_DB::valueQuery($sql, $this->id); - $newOwner = $this->ownerUserID; - - // Move existing owner out of the way, if there is one - if ($currentOwner) { - $sql = "UPDATE groupUsers SET role='admin' WHERE groupID=? AND userID=?"; - Zotero_DB::query($sql, array($this->id, $currentOwner)); - } - - // Make sure new owner exists in DB - if (!Zotero_Users::exists($newOwner)) { - Zotero_Users::addFromWWW($newOwner); - } - - // Add new owner to group - $sql = "INSERT INTO groupUsers (groupID, userID, role, joined) VALUES - (?, ?, 'owner', CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE - role='owner', lastUpdated=CURRENT_TIMESTAMP"; - Zotero_DB::query($sql, array($this->id, $newOwner)); - - // Delete any record of this user losing access to the group - $libraryID = Zotero_Users::getLibraryIDFromUserID($this->ownerUserID); - $sql = "DELETE FROM syncDeleteLogIDs WHERE libraryID=? AND objectType='group' AND id=?"; - Zotero_DB::query($sql, array($libraryID, $this->id), Zotero_Shards::getByLibraryID($this->libraryID)); - - // Send library removal notification for all API keys belonging to the former owner - // with access to this group - $apiKeys = Zotero_Keys::getUserKeysWithLibrary($currentOwner, $this->libraryID); - Zotero_Notifier::trigger('remove', 'apikey-library', array_map(function ($key) { - return $key->id . "-" . $this->libraryID; - }, $apiKeys)); - - // Send library add notification for all API keys belonging to the new owner - // with access to this group - $apiKeys = Zotero_Keys::getUserKeysWithLibrary($newOwner, $this->libraryID); - Zotero_Notifier::trigger('add', 'apikey-library', array_map(function ($key) { - return $key->id . "-" . $this->libraryID; - }, $apiKeys)); - } - - // If any of the group's users have a queued upload, flag group for a timestamp - // update once the sync is done so that the uploading user gets the change - try { - $userIDs = self::getUsers(); - foreach ($userIDs as $userID) { - if ($syncUploadQueueID = Zotero_Sync::getUploadQueueIDByUserID($userID)) { - Zotero_Sync::postWriteLog($syncUploadQueueID, 'group', $this->id, 'update'); - } - } - } - catch (Exception $e) { - Z_Core::logError($e); - } - - Zotero_DB::commit(); - - $this->load(); - - return $libraryID; - } - - - public function erase() { - if (!$this->loaded) { - Z_Core::debug("Not deleting unloaded group $this->id"); - return; - } - - Zotero_DB::beginTransaction(); - - $userIDs = self::getUsers(); - - $this->logGroupLibraryRemoval(); - - Zotero_Libraries::deleteCachedData($this->libraryID); - Zotero_Libraries::clearAllData($this->libraryID); - - $sql = "DELETE FROM shardLibraries WHERE libraryID=?"; - $deleted = Zotero_DB::query($sql, $this->libraryID, Zotero_Shards::getByLibraryID($this->libraryID)); - if (!$deleted) { - throw new Exception("Group not deleted"); - } - - $sql = "DELETE FROM libraries WHERE libraryID=?"; - $deleted = Zotero_DB::query($sql, $this->libraryID); - if (!$deleted) { - throw new Exception("Group not deleted"); - } - - // Delete key permissions for this library, and then delete any keys - // that had no other permissions - $sql = "SELECT keyID FROM keyPermissions WHERE libraryID=?"; - $keyIDs = Zotero_DB::columnQuery($sql, $this->libraryID); - if ($keyIDs) { - $sql = "DELETE FROM keyPermissions WHERE libraryID=?"; - Zotero_DB::query($sql, $this->libraryID); - - $sql = "DELETE K FROM `keys` K LEFT JOIN keyPermissions KP USING (keyID) - WHERE keyID IN (" - . implode(', ', array_fill(0, sizeOf($keyIDs), '?')) - . ") AND KP.keyID IS NULL"; - Zotero_DB::query($sql, $keyIDs); - } - - // If group is locked by a sync, flag group for a timestamp update - // once the sync is done so that the uploading user gets the change - try { - foreach ($userIDs as $userID) { - if ($syncUploadQueueID = Zotero_Sync::getUploadQueueIDByUserID($userID)) { - Zotero_Sync::postWriteLog($syncUploadQueueID, 'group', $this->id, 'delete'); - } - } - } - catch (Exception $e) { - Z_Core::logError($e); - } - - Zotero_Notifier::trigger( - 'delete', - 'library', - $this->libraryID, - [ - $this->libraryID => [ - 'type' => 'group', - 'libraryTypeID' => $this->id - ] - ] - ); - - Zotero_DB::commit(); - - $this->erased = true; - } - - - /** - * Converts group to a SimpleXMLElement item - * - * @return SimpleXMLElement Group data as SimpleXML element - */ - public function toHTML() { - if (($this->id || $this->libraryID) && !$this->loaded) { - $this->load(); - } - - $html = new SimpleXMLElement(""); - - $tr = Zotero_Atom::addHTMLRow( - $html, - 'owner', - "Owner", - "", - true - ); - $tr->td->a = Zotero_Users::getUsername($this->ownerUserID); - $tr->td->a['href'] = Zotero_URI::getUserURI($this->ownerUserID); - - Zotero_Atom::addHTMLRow($html, '', "Type", preg_replace('/([a-z])([A-Z])/', '$1 $2', $this->type)); - - Zotero_Atom::addHTMLRow($html, '', "Description", $this->description); - Zotero_Atom::addHTMLRow($html, '', "URL", $this->url); - - Zotero_Atom::addHTMLRow($html, '', "Library Reading", ucwords($this->libraryReading)); - Zotero_Atom::addHTMLRow($html, '', "Library Editing", ucwords($this->libraryEditing)); - Zotero_Atom::addHTMLRow($html, '', "File Editing", ucwords($this->fileEditing)); - - $admins = $this->getAdmins(); - if ($admins) { - $tr = Zotero_Atom::addHTMLRow($html, '', "Admins", '', true); - $ul = $tr->td->addChild('ul'); - foreach ($admins as $admin) { - $li = $ul->addChild('li'); - $li->a = Zotero_Users::getUsername($admin); - $li->a['href'] = Zotero_URI::getUserURI($admin); - } - } - - return $html; - } - - - public function toResponseJSON(array $requestParams) { - if (!$this->loaded) { - $this->load(); - } - - $json = [ - 'id' => $this->id, - 'version' => $this->version - ]; - - $json['links'] = [ - 'self' => [ - 'href' => Zotero_API::getGroupURI($this), - 'type' => 'application/json' - ], - 'alternate' => [ - 'href' => Zotero_URI::getGroupURI($this, true), - 'type' => 'text/html' - ] - ]; - $json['meta'] = [ - 'created' => Zotero_Date::sqlToISO8601($this->dateAdded), - 'lastModified' => Zotero_Date::sqlToISO8601($this->dateModified), - 'numItems' => $this->numItems() - ]; - - $json['data'] = $this->toJSON($requestParams, true); - - return $json; - } - - - /** - * Converts group to a JSON object - */ - public function toJSON(array $requestParams, $includeEmpty=false) { - if (($this->id || $this->libraryID) && !$this->loaded) { - $this->load(); - } - - $arr = array(); - if ($requestParams['v'] >= 3) { - $arr['id'] = $this->id; - $arr['version'] = $this->version; - } - $arr['name'] = $this->name; - $arr['owner'] = $this->ownerUserID; - $arr['type'] = $this->type; - - if ($this->description || $includeEmpty) { - $arr['description'] = $this->description; - } - - if ($this->url || $includeEmpty) { - $arr['url'] = $this->url; - } - if ($this->hasImage) { - $arr['hasImage'] = 1; - } - - $arr['libraryEditing'] = $this->libraryEditing; - $arr['libraryReading'] = $this->libraryReading; - $arr['fileEditing'] = $this->fileEditing; - - $admins = $this->getAdmins(); - if ($admins) { - $arr['admins'] = $admins; - } - - $members = $this->getMembers(); - if ($members) { - $arr['members'] = $members; - } - - return $arr; - } - - - /** - * Converts group to a SimpleXMLElement item - * - * @return SimpleXMLElement Group data as SimpleXML element - */ - public function toXML($userID=false) { - if (($this->id || $this->libraryID) && !$this->loaded) { - $this->load(); - } - - $syncMode = !!$userID; - - $xml = 'id; - if ($syncMode) { - $xml['libraryID'] = $this->libraryID; - } - else { - $xml['owner'] = $this->ownerUserID; - $xml['type'] = $this->type; - } - $xml['name'] = $this->name; - if ($syncMode) { - $xml['editable'] = (int) $this->userCanEdit($userID); - $xml['filesEditable'] = (int) $this->userCanEditFiles($userID); - } - else { - $xml['libraryEditing'] = $this->libraryEditing; - $xml['libraryReading'] = $this->libraryReading; - $xml['fileEditing'] = $this->fileEditing; - } - if ($this->description) { - $xml->description = $this->description; - } - if (!$syncMode && $this->url) { - $xml->url = $this->url; - } - if (!$syncMode && $this->hasImage) { - $xml['hasImage'] = 1; - } - - if (!$syncMode) { - $admins = $this->getAdmins(); - if ($admins) { - $xml->admins = implode(' ', $admins); - } - - $members = $this->getMembers(); - if ($members) { - $xml->members = implode(' ', $members); - } - } - - return $xml; - } - - - public function toAtom($queryParams) { - if (!empty($queryParams['content'])) { - $content = $queryParams['content']; - } - else { - $content = array('none'); - } - // TEMP: multi-format support - $content = $content[0]; - - if (!$this->loaded) { - $this->load(); - } - - $xml = new SimpleXMLElement( - '' - . '' - ); - - $title = $this->name ? $this->name : '[Untitled]'; - $xml->title = $title; - - $author = $xml->addChild('author'); - $ownerLibraryID = Zotero_Users::getLibraryIDFromUserID($this->ownerUserID); - $author->name = Zotero_Users::getUsername($this->ownerUserID); - $author->uri = Zotero_URI::getLibraryURI($ownerLibraryID); - - $xml->id = Zotero_URI::getGroupURI($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::getGroupURI($this); - - $link = $xml->addChild('link'); - $link['rel'] = 'alternate'; - $link['type'] = 'text/html'; - $link['href'] = Zotero_URI::getGroupURI($this); - - $xml->addChild( - 'zapi:groupID', - $this->id, - Zotero_Atom::$nsZoteroAPI - ); - - $xml->addChild( - 'zapi:numItems', - $this->numItems(), - Zotero_Atom::$nsZoteroAPI - ); - - if ($content == 'html') { - $xml->content['type'] = 'html'; - $htmlXML = $this->toHTML(); - $xml->content->div = ''; - $xml->content->div['xmlns'] = Zotero_Atom::$nsXHTML; - $fNode = dom_import_simplexml($xml->content->div); - $subNode = dom_import_simplexml($htmlXML); - $importedNode = $fNode->ownerDocument->importNode($subNode, true); - $fNode->appendChild($importedNode); - } - else if ($content == 'json') { - $xml->content['type'] = 'application/json'; - $xml->content['etag'] = $this->etag; - // Deprecated - if ($queryParams['v'] < 2) { - $xml->content->addAttribute( - "zapi:etag", - $this->etag, - Zotero_Atom::$nsZoteroAPI - ); - } - $xml->content = Zotero_Utilities::formatJSON($this->toJSON($queryParams, true)); - } - else if ($content == 'full') { - $xml->content['type'] = 'application/xml'; - $fullXML = $this->toXML(); - $fNode = dom_import_simplexml($xml->content); - $subNode = dom_import_simplexml($fullXML); - $importedNode = $fNode->ownerDocument->importNode($subNode, true); - $fNode->appendChild($importedNode); - } - - return $xml; - } - - - public function memberToAtom($userID) { - if (!is_int($userID)) { - throw new Exception("userID must be an integer (was " . gettype($userID) . ")"); - } - - if (!$this->loaded) { - $this->load(); - } - - $groupUserData = $this->getUserData($userID); - if (!$groupUserData) { - throw new Exception("User $userID is not a member of group $this->id", Z_ERROR_USER_NOT_GROUP_MEMBER); - } - - $xml = new SimpleXMLElement( - '' - . '' - ); - - // If we know the username, provide that - // TODO: get and cache full names - if (Zotero_Users::exists($userID)) { - $title = Zotero_Users::getUsername($userID); - } - else { - $title = "User $userID"; - } - $xml->title = $title; - - $author = $xml->addChild('author'); - $author->name = "Zotero"; - $author->uri = "http://zotero.org"; - - $xml->id = Zotero_URI::getGroupUserURI($this, $userID); - - $xml->published = Zotero_Date::sqlToISO8601($groupUserData['joined']); - $xml->updated = Zotero_Date::sqlToISO8601($groupUserData['lastUpdated']); - - $link = $xml->addChild("link"); - $link['rel'] = "self"; - $link['type'] = "application/atom+xml"; - $link['href'] = Zotero_API::getGroupUserURI($this, $userID); - - $link = $xml->addChild('link'); - $link['rel'] = 'alternate'; - $link['type'] = 'text/html'; - $link['href'] = Zotero_URI::getGroupUserURI($this, $userID); - - $xml->content['type'] = 'application/xml'; - - $userXML = new SimpleXMLElement( - '' - ); - // This method of adding the element seems to be necessary to get the - // namespace prefix to show up - $fNode = dom_import_simplexml($xml->content); - $subNode = dom_import_simplexml($userXML); - $importedNode = $fNode->ownerDocument->importNode($subNode, true); - $fNode->appendChild($importedNode); - - $xml->content->user['id'] = $userID; - $xml->content->user['role'] = $groupUserData['role']; - - return $xml; - } - - - public function itemToAtom($itemID) { - if (!is_int($itemID)) { - throw new Exception("itemID must be an integer (was " . gettype($itemID) . ")"); - } - - if (!$this->loaded) { - $this->load(); - } - - //$groupUserData = $this->getUserData($itemID); - $item = Zotero_Items::get($this->libraryID, $itemID); - if (!$item) { - throw new Exception("Item $itemID doesn't exist"); - } - - $xml = new SimpleXMLElement( - '' - . '' - ); - - $title = $item->getDisplayTitle(true); - $title = $title ? $title : '[Untitled]'; - // Strip HTML from note titles - if ($item->isNote()) { - // Clean and strip HTML, giving us an HTML-encoded plaintext string - $title = strip_tags(Zotero_Notes::sanitize($title)); - // Unencode plaintext string - $title = html_entity_decode($title); - } - $xml->title = $title; - - $author = $xml->addChild('author'); - $author->name = Zotero_Libraries::getName($item->libraryID); - $author->uri = Zotero_URI::getLibraryURI($item->libraryID); - - $xml->id = Zotero_URI::getItemURI($item); - - $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"; - $link['href'] = Zotero_API::getItemURI($item); - - $link = $xml->addChild('link'); - $link['rel'] = 'alternate'; - $link['type'] = 'text/html'; - $link['href'] = Zotero_URI::getItemURI($item, true); - - $xml->content['type'] = 'application/xml'; - - $itemXML = new SimpleXMLElement( - '' - ); - // This method of adding the element seems to be necessary to get the - // namespace prefix to show up - $fNode = dom_import_simplexml($xml->content); - $subNode = dom_import_simplexml($itemXML); - $importedNode = $fNode->ownerDocument->importNode($subNode, true); - $fNode->appendChild($importedNode); - - $xml->content->item['id'] = $itemID; - - return $xml; - } - - - private function load() { - $sql = "SELECT * FROM groups WHERE groupID=?"; - $row = Zotero_DB::rowQuery($sql, $this->id); - if (!$row) { - return false; - } - - foreach ($row as $field=>$value) { - switch ($field) { - case 'groupID': - case 'slug': - continue 2; - } - - $this->$field = $value; - } - - $sql = "SELECT userID FROM groupUsers WHERE groupID=? AND role='owner'"; - $userID = Zotero_DB::valueQuery($sql, $this->id); - if (!$userID) { - throw new Exception("Group $this->id doesn't have an owner"); - } - $this->ownerUserID = $userID; - - $this->loaded = true; - $this->changed = array(); - } - - - private function getETag() { - if (!$this->loaded) { - $this->load(); - } - return md5($this->dateModified . $this->version); - } - - - private function logGroupLibraryRemoval() { - $users = $this->getUsers(); - - $usersByShard = array(); - foreach ($users as $userID) { - $shardID = Zotero_Shards::getByUserID($userID); - if (!isset($usersByShard[$shardID])) { - $usersByShard[$shardID] = array(); - } - $usersByShard[$shardID][] = $userID; - } - - foreach ($usersByShard as $shardID=>$userIDs) { - // Add to delete log for all group members - $sql = "REPLACE INTO syncDeleteLogIDs (libraryID, objectType, id) VALUES "; - $params = array(); - $sets = array(); - foreach ($userIDs as $userID) { - $libraryID = Zotero_Users::getLibraryIDFromUserID($userID); - $sets[] = "(?,?,?)"; - $params = array_merge($params, array($libraryID, 'group', $this->id)); - } - $sql .= implode(",", $sets); - Zotero_DB::query($sql, $params, $shardID); - } - } -} -?> diff --git a/model/Groups.inc.php b/model/Groups.inc.php deleted file mode 100644 index 125e98d6..00000000 --- a/model/Groups.inc.php +++ /dev/null @@ -1,476 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_Groups { - private static $groups = array(); - - public static function get($groupID, $skipExistsCheck=false) { - if (!$groupID) { - throw new Exception('$groupID not set'); - } - - if (isset(self::$groups[$groupID])) { - $group = self::$groups[$groupID]; - if ($group->erased) { - unset(self::$groups[$groupID]); - return false; - } - return $group; - } - - $group = new Zotero_Group; - $group->id = $groupID; - if (!$skipExistsCheck && !$group->exists()) { - return false; - } - - self::$groups[$groupID] = $group; - - return self::$groups[$groupID]; - } - - - public static function getAllAdvanced($userID=false, $params=array(), $permissions=null) { - $buffer = 20; - $maxTimes = 3; - - $groups = array(); - $start = !empty($params['start']) ? $params['start'] : 0; - $limit = !empty($params['limit']) ? $params['limit'] + $buffer : false; - $totalResults = null; - - $times = 0; - while (true) { - if ($times > 0) { - Z_Core::logError('Getting more groups in Zotero_Groups::getAllAdvanced()'); - } - - $calcFoundRows = !$totalResults; - $cacheFoundRows = $calcFoundRows && !$userID; - // If we don't yet have a row count and this isn't a user-specific search, - // try to get a cached row count. - if ($cacheFoundRows) { - $foundRowsCacheKey = md5(self::getCacheComponentFromParam($params, 'q') . "," - . self::getCacheComponentFromParam($params, 'fq')); - $foundRowsTTL = 3600; - $foundRowsLockTTL = 120; - $foundRowsRealTTL = 7200; - - $obj = Z_Core::$MC->get($foundRowsCacheKey); - if ($obj) { - $foundRows = $obj['rows']; - $exp = $obj['exp']; - // If count was found but is past the expiration time, check if another - // request is getting the row count, and fetch it if not - if ($exp < time()) { - if (!Z_Core::$MC->add($foundRowsCacheKey . "Lock", true, $foundRowsLockTTL)) { - $calcFoundRows = false; - } - } - else { - $calcFoundRows = false; - } - } - } - - $sql = "SELECT " - // Use SQL_CALC_FOUND_ROWS for user queries - . (($calcFoundRows && $userID) ? "SQL_CALC_FOUND_ROWS " : "") - . "G.groupID, GUO.userID AS ownerUserID " - . "FROM groups G JOIN groupUsers GUO ON (G.groupID=GUO.groupID AND GUO.role='owner') "; - $sqlParams = []; - if ($userID) { - $sql .= "JOIN groupUsers GUA ON (G.groupID=GUA.groupID) WHERE GUA.userID=? "; - $sqlParams[] = $userID; - } - - // Run separate query to get Total-Results for non-user queries - $countSQL = "SELECT COUNT(*) FROM groups G "; - $countSQLParams = []; - - $querySQL = ""; - $queryParams = []; - $includeEmpty = false; - if (!empty($params['q'])) { - if (!is_array($params['q'])) { - $params['q'] = array($params['q']); - } - foreach ($params['q'] as $q) { - $field = explode(":", $q); - if (sizeOf($field) == 2) { - switch ($field[0]) { - case 'slug': - $includeEmpty = true; - break; - - default: - throw new Exception("Cannot search by group field '{$field[0]}'", Z_ERROR_INVALID_GROUP_TYPE); - } - - $querySQL .= "AND " . $field[0] - // If first character is '-', negate - . ($field[0][0] == '-' ? '!' : '') - . "=? "; - $queryParams[] = $field[1]; - } - else { - $querySQL .= "AND name LIKE ? "; - $queryParams[] = "%$q%"; - } - } - } - if (!$userID) { - if ($includeEmpty) { - $whereSQL = "WHERE 1 "; - } - else { - // Don't include groups that have never had items - $whereSQL = "JOIN libraries L ON (G.libraryID=L.libraryID) - WHERE L.lastUpdated != '0000-00-00 00:00:00' "; - } - $sql .= $whereSQL; - $countSQL .= $whereSQL; - } - $sql .= $querySQL; - $sqlParams = array_merge($sqlParams, $queryParams); - $countSQL .= $querySQL; - $countSQLParams = array_merge($countSQLParams, $queryParams); - - if (!empty($params['fq'])) { - if (!is_array($params['fq'])) { - $params['fq'] = array($params['fq']); - } - $querySQL = ""; - $queryParams = []; - foreach ($params['fq'] as $fq) { - $facet = explode(":", $fq); - if (sizeOf($facet) == 2 && preg_match('/-?GroupType/', $facet[0])) { - switch ($facet[1]) { - case 'PublicOpen': - case 'PublicClosed': - case 'Private': - break; - - default: - throw new Exception("Invalid group type '{$facet[1]}'", Z_ERROR_INVALID_GROUP_TYPE); - } - - $querySQL .= "AND type" - // If first character is '-', negate - . ($facet[0][0] == '-' ? '!' : '') - . "=? "; - $queryParams[] = $facet[1]; - } - } - - $sql .= $querySQL; - $sqlParams = array_merge($sqlParams, $queryParams); - $countSQL .= $querySQL; - $countSQLParams = array_merge($countSQLParams, $queryParams); - } - - if (!empty($params['sort'])) { - $order = $params['sort']; - if ($order == 'title') { - $order = 'name'; - } - $sql .= "ORDER BY $order"; - if (!empty($params['direction'])) { - $sql .= " " . $params['direction'] . " "; - } - } - - // Limit is set $buffer higher than the actual limit, in case some groups are - // removed during access checks - // - // Actual limiting is done below - if ($limit) { - $sql .= "LIMIT ?, ?"; - $sqlParams[] = $start; - $sqlParams[] = $limit; - } - - $rows = Zotero_DB::query($sql, $sqlParams); - if (!$rows) { - break; - } - - if (is_null($totalResults)) { - if ($calcFoundRows) { - if ($userID) { - $foundRows = Zotero_DB::valueQuery("SELECT FOUND_ROWS()"); - } - else { - $foundRows = Zotero_DB::valueQuery($countSQL, $countSQLParams); - } - // Cache found rows count, and store earlier expiration time so that one - // request can trigger a recalculation before cached value expires - if ($cacheFoundRows) { - Z_Core::$MC->set( - $foundRowsCacheKey, - [ - 'rows' => $foundRows, - 'exp' => time() + $foundRowsTTL - ], - $foundRowsRealTTL - ); - } - } - $totalResults = $foundRows; - } - - // Include only groups with non-banned owners - $owners = array(); - foreach ($rows as $row) { - $owners[] = $row['ownerUserID']; - } - $owners = Zotero_Users::getValidUsers($owners); - $ids = array(); - foreach ($rows as $row) { - if (!in_array($row['ownerUserID'], $owners)) { - $totalResults--; - continue; - } - $ids[] = $row['groupID']; - } - - $batchStartPos = sizeOf($groups); - - foreach ($ids as $id) { - $group = Zotero_Groups::get($id, true); - $groups[] = $group; - } - - // Remove groups that can't be accessed - if ($permissions) { - for ($i=$batchStartPos; $icanAccess($groups[$i]->libraryID, 'view')) { - array_splice($groups, $i, 1); - $i--; - $totalResults--; - } - } - } - - $times++; - if ($times == $maxTimes) { - Z_Core::logError('Too many queries in Zotero_Groups::getAllAdvanced()'); - break; - } - - if (empty($params['limit'])) { - break; - } - - // If we have enough groups to fill the limit, stop - if (sizeOf($groups) > $params['limit']) { - break; - } - - // If there no more rows, stop - if ($start + sizeOf($rows) >= $foundRows) { - break; - } - - $start = $start + sizeOf($rows); - // Get number we still need plus the buffer or all remaining, whichever is lower - $limit = min($params['limit'] - sizeOf($groups) + $buffer, $foundRows - $start); - } - - // TODO: generate previous start value - - if (!$groups) { - return array('results' => array(), 'total' => 0); - } - - // Fake limiting -- we can't just use SQL limit because - // some groups might be inaccessible - if (!empty($params['limit'])) { - $groups = array_slice( - $groups, - 0, - $params['limit'] - ); - } - - $results = array('results' => $groups, 'total' => $totalResults); - return $results; - } - - - private static function getCacheComponentFromParam($params, $param) { - $str = $param . ":"; - if (empty($params[$param])) { - return $str; - } - $val = $params[$param]; - if (!is_array($val)) { - $val = [$val]; - } - else { - ksort($val); - } - return $str . implode($val); - } - - - /** - * Returns groupIDs of groups a user has joined since |timestamp| - * - * @param int $libraryID Library ID - * @param string $timestamp Unix timestamp of last sync time - * @return array An array of groupIDs - */ - public static function getJoined($userID, $timestamp) { - $sql = "SELECT groupID FROM groupUsers WHERE userID=? AND joined>FROM_UNIXTIME(?)"; - $groupIDs = Zotero_DB::columnQuery($sql, array($userID, $timestamp)); - return $groupIDs ? $groupIDs : array(); - } - - - /** - * Returns groupIDs of groups the user is a member of that have been updated since |timestamp| - * - * @param int $libraryID Library ID - * @param string $timestamp Unix timestamp of last sync time - * @return array An array of groupIDs - */ - public static function getUpdated($userID, $timestamp) { - $sql = "SELECT groupID FROM groups G NATURAL JOIN groupUsers GU WHERE userID=? - AND (G.dateModified>FROM_UNIXTIME(?) OR GU.lastUpdated>FROM_UNIXTIME(?))"; - $groupIDs = Zotero_DB::columnQuery($sql, array($userID, $timestamp, $timestamp)); - return $groupIDs ? $groupIDs : array(); - } - - - public static function exist($groupIDs) { - $sql = "SELECT groupID FROM groups WHERE groupID IN (" - . implode(', ', array_fill(0, sizeOf($groupIDs), '?')) . ")"; - $exist = Zotero_DB::columnQuery($sql, $groupIDs); - return $exist ? $exist : array(); - } - - - public static function publicNameExists($name) { - $slug = Zotero_Utilities::slugify($name); - $sql = "SELECT groupID FROM groups WHERE (name=? OR slug=?) AND - type IN ('PublicOpen', 'PublicClosed')"; - $groupID = Zotero_DB::valueQuery($sql, array($name, $slug)); - return $groupID ? $groupID : false; - } - - - public static function getLibraryIDFromGroupID($groupID) { - $cacheKey = 'groupLibraryID_' . $groupID; - $libraryID = Z_Core::$MC->get($cacheKey); - if ($libraryID) { - return $libraryID; - } - $sql = "SELECT libraryID FROM groups WHERE groupID=?"; - $libraryID = Zotero_DB::valueQuery($sql, $groupID); - if (!$libraryID) { - trigger_error("Group $groupID does not exist", E_USER_ERROR); - } - Z_Core::$MC->set($cacheKey, $libraryID); - return $libraryID; - } - - - public static function getGroupIDFromLibraryID($libraryID) { - $cacheKey = 'libraryGroupID_' . $libraryID; - $groupID = Z_Core::$MC->get($cacheKey); - if ($groupID) { - return $groupID; - } - $sql = "SELECT groupID FROM groups WHERE libraryID=?"; - $groupID = Zotero_DB::valueQuery($sql, $libraryID); - if (!$groupID) { - trigger_error("Group with libraryID $libraryID does not exist", E_USER_ERROR); - } - Z_Core::$MC->set($cacheKey, $groupID); - return $groupID; - } - - - public static function getUserGroups($userID) { - $sql = "SELECT groupID FROM groupUsers WHERE userID=?"; - $groups = Zotero_DB::columnQuery($sql, $userID); - if (!$groups) { - return array(); - } - return $groups; - } - - - public static function getUserOwnedGroups($userID) { - $sql = "SELECT G.groupID FROM groups G - JOIN groupUsers GU ON (G.groupID=GU.groupID AND role='owner') - WHERE userID=?"; - $groups = Zotero_DB::columnQuery($sql, $userID); - if (!$groups) { - return array(); - } - return $groups; - } - - - public static function getUserOwnedGroupLibraries($userID) { - $groups = self::getUserOwnedGroups($userID); - $libraries = array(); - foreach ($groups as $group) { - $libraries[] = Zotero_Groups::getLibraryIDFromGroupID($group); - } - return $libraries; - } - - - /** - * Returns shardIDs of all shards storing groups this user belongs to - */ - public static function getUserGroupShards($userID) { - $groupIDs = self::getUserGroups($userID); - if (!$groupIDs) { - return array(); - } - $shardIDs = array(); - foreach ($groupIDs as $groupID) { - $shardID = Zotero_Shards::getByGroupID($groupID); - $shardIDs[$shardID] = true; - } - return array_keys($shardIDs); - } - - - public static function getUserGroupLibraries($userID) { - $sql = "SELECT libraryID FROM groupUsers JOIN groups USING (groupID) WHERE userID=?"; - $libraryIDs = Zotero_DB::columnQuery($sql, $userID); - if (!$libraryIDs) { - return array(); - } - return $libraryIDs; - } -} -?> diff --git a/model/ID.inc.php b/model/ID.inc.php deleted file mode 100644 index ec9d0d29..00000000 --- a/model/ID.inc.php +++ /dev/null @@ -1,97 +0,0 @@ -. - - ***** END LICENSE BLOCK ***** -*/ - -class Zotero_ID { - /* - * Gets an unused primary key id for a DB table - */ - public static function get($table) { - switch ($table) { - case 'collections': - case 'creators': - case 'items': - case 'relations': - case 'savedSearches': - case 'tags': - return self::getNext($table); - - default: - trigger_error("Unsupported table '$table'", E_USER_ERROR); - } - } - - - public static function getKey() { - return Zotero_Utilities::randomString(8, 'key', true); - } - - - public static function isValidKey($key) { - return preg_match('/^[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}$/', $key); - } - - - public static function getBigInt() { - return rand(1, 2147483647); - } - - - /* - * Get MAX(id) + 1 from ids databases - */ - private static function getNext($table) { - $sql = "REPLACE INTO $table (stub) VALUES ('a')"; - if (Z_Core::probability(2)) { - try { - Zotero_ID_DB_1::query($sql); - $id = Zotero_ID_DB_1::valueQuery("SELECT LAST_INSERT_ID()"); - } - catch (Exception $e) { - Z_Core::logError("Error accessing ID server 1"); - Zotero_ID_DB_2::query($sql); - $id = Zotero_ID_DB_2::valueQuery("SELECT LAST_INSERT_ID()"); - } - } - else { - try { - Zotero_ID_DB_2::query($sql); - $id = Zotero_ID_DB_2::valueQuery("SELECT LAST_INSERT_ID()"); - } - catch (Exception $e) { - Z_Core::logError("Error accessing ID server 2"); - Zotero_ID_DB_1::query($sql); - $id = Zotero_ID_DB_1::valueQuery("SELECT LAST_INSERT_ID()"); - } - } - - if (!$id || !is_int($id)) { - throw new Exception("Invalid id $id"); - } - - return $id; - } -} -?> diff --git a/model/Item.inc.php b/model/Item.inc.php deleted file mode 100644 index 24d3a040..00000000 --- a/model/Item.inc.php +++ /dev/null @@ -1,4275 +0,0 @@ -. - - ***** 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 $deleted = null; - private $inPublications = null; - - private $attachmentData = array( - 'linkMode' => null, - 'mimeType' => null, - 'charset' => null, - 'storageModTime' => null, - 'storageHash' => null, - 'path' => null, - 'filename' => null - ); - - private $numNotes; - private $numAttachments; - - protected $collections = []; - protected $tags = []; - - private $cacheEnabled = false; - - 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 'deleted': - return $this->getDeleted(); - - 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 'attachmentPath': - return $this->getAttachmentPath(); - - case 'attachmentFilename': - return $this->getAttachmentFilename(); - - case 'attachmentStorageModTime': - return $this->getAttachmentStorageModTime(); - - case 'attachmentStorageHash': - return $this->getAttachmentStorageHash(); - - case 'relatedItems': - return $this->getRelatedItems(); - - case 'etag': - return $this->getETag(); - } - - 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 '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 ''; - } - } - - 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->id 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::isFieldOfBase($fieldID, 'date')) { - $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; - - if (!$title && ($itemTypeID == 8 || $itemTypeID == 10)) { // 'letter' and 'interview' itemTypeIDs - $creators = $this->getCreators(); - $authors = array(); - $participants = array(); - if ($creators) { - foreach ($creators as $creator) { - if (($itemTypeID == 8 && $creator['creatorTypeID'] == 16) || // 'letter'/'recipient' - ($itemTypeID == 10 && $creator['creatorTypeID'] == 7)) { // 'interview'/'interviewer' - $participants[] = $creator; - } - else if (($itemTypeID == 8 && $creator['creatorTypeID'] == 1) || // 'letter'/'author' - ($itemTypeID == 10 && $creator['creatorTypeID'] == 6)) { // 'interview'/'interviewee' - $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 == 8) { - $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 .= ']'; - } - else if ($itemTypeID == 17) { // 'case' itemTypeID - 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'] === 1) { - $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 = false; - } - - if ($value !== false && !Zotero_ItemFields::isValidForType($fieldID, $this->_itemTypeID)) { - throw new Exception("'$field' 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::isFieldOfBase($fieldID, 'date') && - !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 isImportedAttachment() { - if (!$this->isAttachment()) { - return false; - } - $name = $this->attachmentLinkMode; - return $name == "imported_file" || $name == "imported_url"; - } - - - 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 getDeleted() { - if ($this->deleted !== null) { - return $this->deleted; - } - - if (!$this->__get('id')) { - return false; - } - - if (!is_numeric($this->id)) { - throw new Exception("Invalid itemID"); - } - - if ($this->cacheEnabled) { - $cacheVersion = 1; - $cacheKey = $this->getCacheKey("itemIsDeleted", - $cacheVersion - . isset(Z_CONFIG::$CACHE_VERSION_ITEM_DATA) - ? "_" . Z_CONFIG::$CACHE_VERSION_ITEM_DATA - : "" - ); - $deleted = Z_Core::$MC->get($cacheKey); - } - else { - $deleted = false; - } - if ($deleted === false) { - $sql = "SELECT COUNT(*) FROM deletedItems WHERE itemID=?"; - $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID)); - $deleted = !!Zotero_DB::valueQueryFromStatement($stmt, $this->id); - - // Memcache returns false for empty keys, so use integer - if ($this->cacheEnabled) { - Z_Core::$MC->set($cacheKey, $deleted ? 1 : 0); - } - } - - $this->deleted = $deleted; - - return $deleted; - } - - - private function setDeleted($val) { - $deleted = !!$val; - - if ($this->getDeleted() == $deleted) { - Z_Core::debug("Deleted state ($deleted) hasn't changed for item $this->id"); - return; - } - - if (empty($this->changed['deleted'])) { - $this->changed['deleted'] = true; - } - $this->deleted = $deleted; - } - - - 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); - - $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(); - - // - // 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; - } - - // Group item data - if (Zotero_Libraries::getType($this->_libraryID) == 'group' && $userID) { - $sql = "INSERT INTO groupItems VALUES (?, ?, ?)"; - Zotero_DB::query($sql, array($itemID, $userID, $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( - $this->_itemTypeID, $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'])) { - $deleted = $this->getDeleted(); - if ($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 (!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; - - $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 (?,?,?,?,?,?,?,?)"; - $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()) { - $parentKey = $parentItem->key; - throw new Exception("=Parent item $parentKey cannot be a child attachment", 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); - } - - // 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(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; - } - } - - // Collections - if (!empty($this->changed['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); - $collection->save(); - } - } - - // Tags - if (!empty($this->changed['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'])) { - $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] - ) - ); - } - } - - // 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); - } - - // - // 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 (Zotero_Libraries::getType($this->_libraryID) == 'group' && $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( - $this->_itemTypeID, $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); - } - - - // In case this was previously a standalone item, - // delete from any collections it may have been in - if (!empty($this->changed['source']) && $this->getSource()) { - $sql = "DELETE FROM collectionItems WHERE itemID=?"; - Zotero_DB::query($sql, $this->_id, $shardID); - } - - // - // Note or attachment note - // - if (!empty($this->changed['note'])) { - // 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=?, note=?, noteSanitized=?, title=?, hash=?"; - $bindParams = array( - $this->_id, - $sourceItemID, $this->noteText, $this->noteTextSanitized, $this->noteTitle, $hash, - $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'])) { - $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()) { - $parentKey = $parentItem->key; - throw new Exception("=Parent item $parentKey cannot be a child attachment", 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( - $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); - } - } - - // 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)); - if (mb_substr($sortTitle, 0, 5) == mb_substr($this->getField('title', false, true), 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); - - // Update DB, if not a note or attachment we already changed above - if (empty($this->changed['attachmentData']) && (empty($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 && !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); - $collection->save(); - } - - foreach ($toRemove as $collectionKey) { - $collection = Zotero_Collections::getByLibraryAndKey($this->_libraryID, $collectionKey); - $collection->removeItem($this->_id); - $collection->save(); - } - } - - 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()); - } - - - public function isTopLevelItem() { - return $this->isRegularItem() || !$this->getSourceKey(); - } - - - public function numChildren($includeTrashed=false) { - return $this->numNotes($includeTrashed) + $this->numAttachments($includeTrashed); - } - - // 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 - // - // - /** - * 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 { - 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) { - $sql = "SELECT sourceItemID 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 { - return false; - } - - $sql = "SELECT `key` FROM item{$Type}s A JOIN items B ON (A.sourceItemID=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 { - throw new Exception("setSource() can be called only on notes and attachments"); - } - - $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 { - throw new Exception("setSourceKey() can be called only on notes and attachments"); - } - - $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()) { - trigger_error("numAttachments() can only be called on regular items", E_USER_ERROR); - } - - 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 (!$this->isNote() && !$this->isAttachment()) { - trigger_error("setNote() can only be called on notes and attachments", E_USER_ERROR); - } - - 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 getAttachmentPath() { - if (!$this->isAttachment()) { - trigger_error("attachmentPath can only be retrieved for attachment items", E_USER_ERROR); - } - - if ($this->attachmentData['path'] !== null) { - return $this->attachmentData['path']; - } - - if (!$this->id) { - return ''; - } - - $sql = "SELECT path FROM itemAttachments WHERE itemID=?"; - $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID)); - $path = Zotero_DB::valueQueryFromStatement($stmt, $this->id); - if (!$path) { - $path = ''; - } - $this->attachmentData['path'] = $path; - return $path; - } - - - private function getAttachmentFilename() { - if (!$this->isAttachment()) { - throw new Exception("attachmentFilename can only be retrieved for attachment items"); - } - - if (!$this->isImportedAttachment()) { - 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 getAttachmentStorageModTime() { - if (!$this->isAttachment()) { - trigger_error("attachmentStorageModTime can only be retrieved - for attachment items", E_USER_ERROR); - } - - if ($this->attachmentData['storageModTime'] !== null) { - return $this->attachmentData['storageModTime']; - } - - if (!$this->id) { - return null; - } - - $sql = "SELECT storageModTime FROM itemAttachments WHERE itemID=?"; - $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID)); - $val = Zotero_DB::valueQueryFromStatement($stmt, $this->id); - $this->attachmentData['storageModTime'] = $val; - return $val; - } - - - private function getAttachmentStorageHash() { - if (!$this->isAttachment()) { - trigger_error("attachmentStorageHash can only be retrieved - for attachment items", E_USER_ERROR); - } - - if ($this->attachmentData['storageHash'] !== null) { - return $this->attachmentData['storageHash']; - } - - if (!$this->id) { - return null; - } - - $sql = "SELECT storageHash FROM itemAttachments WHERE itemID=?"; - $stmt = Zotero_DB::getStatement($sql, true, Zotero_Shards::getByLibraryID($this->libraryID)); - $val = Zotero_DB::valueQueryFromStatement($stmt, $this->id); - $this->attachmentData['storageHash'] = $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) { - 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; - - 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); - } - - if ($field == 'filename') { - $linkMode = $this->getAttachmentLinkMode(); - 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; - } - - - /** - * 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; - } - - - - // - // 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($asSimpleXML = false, $requestParams) { - $html = new SimpleXMLElement('
'); - - /* - // Title - $tr = $html->addChild('tr'); - $tr->addAttribute('class', 'title'); - $tr->addChild('th', Zotero_ItemFields::getLocalizedString(false, 'title')); - $tr->addChild('td', htmlspecialchars($item->getDisplayTitle(true))); - */ - - // Item type - Zotero_Atom::addHTMLRow( - $html, - "itemType", - Zotero_ItemFields::getLocalizedString(false, '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(false, $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();
-		$downloadDetails = 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']);
-			}
-		}
-		if ($isRegularItem) {
-			if ($requestParams['publications']) {
-				$numChildren = $this->numPublicationsChildren();
-			}
-			else if ($permissions->canAccess($this->libraryID, 'notes')) {
-				$numChildren = $this->numChildren();
-			}
-			else {
-				$numChildren = $this->numAttachments();
-			}
-		}
-		else {
-			$numChildren = false;
-		}
-		
-		return [
-			"downloadDetails" => $downloadDetails,
-			"numChildren" => $numChildren
-		];
-	}
-	
-	
-	public function toResponseJSON($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);
-		$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)
-				. ($downloadDetails ? 'hasFile' : '')
-				// 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) {
-			// Make sure numChildren reflects the current permissions
-			if ($isRegularItem) {
-				$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 ($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);
-				}
-			}
-			
-			$json['meta']->numChildren = $numChildren;
-		}
-		
-		// 'include'
-		$include = $requestParams['include'];
-		
-		foreach ($include as $type) {
-			if ($type == 'html') {
-				$json[$type] = trim($this->toHTML($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();
-		
-		$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);
-			if ($includeEmpty || $this->itemData[$titleFieldID] !== false) {
-				$arr[$titleFieldName] = $this->itemData[$titleFieldID] !== false ? $this->itemData[$titleFieldID] : "";
-			}
-			
-			// 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 === "")) {
-				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 !== "") {
-					$value = Zotero_Date::sqlToISO8601($value);
-				}
-			}
-			$arr[$fieldName] = ($value !== false && $value !== "") ? $value : "";
-		}
-		
-		// Embedded note for notes and attachments
-		if (!$regularItem) {
-			// Use sanitized version
-			$arr['note'] = $this->getNote(true);
-		}
-		
-		if ($this->isAttachment()) {
-			$arr['linkMode'] = $this->attachmentLinkMode;
-			
-			$val = $this->attachmentMIMEType;
-			if ($includeEmpty || ($val !== false && $val !== "")) {
-				$arr['contentType'] = $val;
-			}
-			
-			$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->isImportedAttachment()) {
-				$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);
-				}
-			}
-		}
-		
-		// Non-field properties, which don't get shown for publications endpoints
-		if (!$isPublications) {
-			if ($this->getDeleted()) {
-				$arr['deleted'] = 1;
-			}
-			
-			if ($this->getPublications()) {
-				$arr['inPublications'] = true;
-			}
-			
-			// 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) {
-				if ($this->isTopLevelItem()) {
-					$collections = $this->getCollections(true);
-					$arr['collections'] = $collections;
-				}
-				
-				$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);
-		}
-	}
-}
-?>
diff --git a/model/ItemFields.inc.php b/model/ItemFields.inc.php
deleted file mode 100644
index bf3bd16d..00000000
--- a/model/ItemFields.inc.php
+++ /dev/null
@@ -1,619 +0,0 @@
-.
-    
-    ***** END LICENSE BLOCK *****
-*/
-
-class Zotero_ItemFields {
-	private static $customFieldCheck = array();
-	
-	// Caches
-	private static $fieldIDCache = array();
-	private static $fieldNameCache = array();
-	private static $itemTypeFieldsCache = array();
-	private static $itemTypeBaseFieldIDCache = array();
-	private static $isValidForTypeCache = array();
-	private static $isBaseFieldCache = array();
-	private static $typeFieldIDsByBaseCache = array();
-	private static $typeFieldNamesByBaseCache = array();
-	
-	private static $localizedFields = array(
-			"itemType"			=> "Type",
-			"title"        		=> "Title",
-			"dateAdded"    		=> "Date Added",
-			"dateModified" 		=> "Modified",
-			"source"       		=> "Source",
-			"notes"				=> "Notes",
-			"tags"				=> "Tags",
-			"attachments"		=> "Attachments",
-			"related"			=> "Related",
-			"url"				=> "URL",
-			"rights"       		=> "Rights",
-			"series"	    		=> "Series",
-			"volume"       		=> "Volume",
-			"issue"	       		=> "Issue",
-			"edition"      		=> "Edition",
-			"place"        		=> "Place",
-			"publisher"    		=> "Publisher",
-			"pages"        		=> "Pages",
-			"ISBN"         		=> "ISBN",
-			"publicationTitle"	=> "Publication",
-			"ISSN"         		=> "ISSN",
-			"date"					=> "Date",
-			"section"				=> "Section",
-			"callNumber"			=> "Call Number",
-			"archiveLocation"		=> "Loc. in Archive",
-			"distributor"			=> "Distributor",
-			"extra"				=> "Extra",
-			"journalAbbreviation"	=> "Journal Abbr",
-			"DOI"					=> "DOI",
-			"accessDate"			=> "Accessed",
-			"seriesTitle"    		=> "Series Title",
-			"seriesText"    		=> "Series Text",
-			"seriesNumber"   		=> "Series Number",
-			"institution"			=> "Institution",
-			"reportType"			=> "Report Type",
-			"code"					=> "Code",
-			"session"				=> "Session",
-			"legislativeBody"		=> "Legislative Body",
-			"history"				=> "History",
-			"reporter"				=> "Reporter",
-			"court"				=> "Court",
-			"numberOfVolumes"		=> "# of Volumes",
-			"committee"			=> "Committee",
-			"assignee"				=> "Assignee",
-			"patentNumber"			=> "Patent Number",
-			"priorityNumbers"		=> "Priority Numbers",
-			"issueDate"			=> "Issue Date",
-			"references"			=> "References",
-			"legalStatus"			=> "Legal Status",
-			"codeNumber"			=> "Code Number",
-			"artworkMedium"			=> "Medium",
-			"number"				=> "Number",
-			"artworkSize"			=> "Artwork Size",
-			"libraryCatalog"		=> "Library Catalog",
-			"repository"			=> "Repository",
-			"videoRecordingFormat"	=> "Format",
-			"interviewMedium"		=> "Medium",
-			"letterType"			=> "Type",
-			"manuscriptType"		=> "Type",
-			"mapType"				=> "Type",
-			"scale"				=> "Scale",
-			"thesisType"			=> "Type",
-			"websiteType"			=> "Website Type",
-			"audioRecordingFormat"	=> "Format",
-			"label"				=> "Label",
-			"presentationType"	=> "Type",
-			"meetingName"			=> "Meeting Name",
-			"studio"				=> "Studio",
-			"runningTime"			=> "Running Time",
-			"network"				=> "Network",
-			"postType"				=> "Post Type",
-			"audioFileType"		=> "File Type",
-			"versionNumber"			=> "Version",
-			"system"				=> "System",
-			"company"				=> "Company",
-			"conferenceName"		=> "Conference Name",
-			"encyclopediaTitle"		=> "Encyclopedia Title",
-			"dictionaryTitle"		=> "Dictionary Title",
-			"language"				=> "Language",
-			"programmingLanguage"	=> "Language",
-			"university"			=> "University",
-			"abstractNote"			=> "Abstract",
-			"websiteTitle"			=> "Website Title",
-			"reportNumber"			=> "Report Number",
-			"billNumber"			=> "Bill Number",
-			"codeVolume"			=> "Code Volume",
-			"codePages"				=> "Code Pages",
-			"dateDecided"			=> "Date Decided",
-			"reporterVolume"		=> "Reporter Volume",
-			"firstPage"				=> "First Page",
-			"documentNumber"		=> "Document Number",
-			"dateEnacted"			=> "Date Enacted",
-			"publicLawNumber"		=> "Public Law Number",
-			"country"				=> "Country",
-			"applicationNumber"		=> "Application Number",
-			"forumTitle"			=> "Forum/Listserv Title",
-			"episodeNumber"			=> "Episode Number",
-			"blogTitle"				=> "Blog Title",
-			"medium"				=> "Medium",
-			"caseName"				=> "Case Name",
-			"nameOfAct"				=> "Name of Act",
-			"subject"				=> "Subject",
-			"proceedingsTitle"		=> "Proceedings Title",
-			"bookTitle"				=> "Book Title",
-			"shortTitle"			=> "Short Title",
-			"docketNumber"			=> "Docket Number",
-			"numPages"				=> "# of Pages",
-			"programTitle"			=> "Program Title",
-			"issuingAuthority"		=> "Issuing Authority",
-			"filingDate"			=> "Filing Date",
-			"genre"					=> "Genre",
-			"archive"				=> "Archive"
-		);
-
-	
-	public static function getID($fieldOrFieldID) {
-		// TODO: batch load
-		
-		if (isset(self::$fieldIDCache[$fieldOrFieldID])) {
-			return self::$fieldIDCache[$fieldOrFieldID];
-		}
-		
-		$cacheKey = "itemFieldID_" . $fieldOrFieldID;
-		$fieldID = Z_Core::$MC->get($cacheKey);
-		if ($fieldID) {
-			// casts are temporary until memcached reload
-			self::$fieldIDCache[$fieldOrFieldID] = (int) $fieldID;
-			return (int) $fieldID;
-		}
-		
-		$sql = "(SELECT fieldID FROM fields WHERE fieldID=?) UNION
-				(SELECT fieldID FROM fields WHERE fieldName=?) LIMIT 1";
-		$fieldID = Zotero_DB::valueQuery($sql, array($fieldOrFieldID, $fieldOrFieldID));
-		
-		self::$fieldIDCache[$fieldOrFieldID] = $fieldID ? (int) $fieldID : false;
-		Z_Core::$MC->set($cacheKey, (int) $fieldID);
-		
-		return $fieldID ? (int) $fieldID : false;
-	}
-	
-	
-	public static function getName($fieldOrFieldID) {
-		if (isset(self::$fieldNameCache[$fieldOrFieldID])) {
-			return self::$fieldNameCache[$fieldOrFieldID];
-		}
-		
-		$cacheVersion = 1;
-		
-		$cacheKey = "itemFieldName_" . $fieldOrFieldID . "_$cacheVersion";
-		$fieldName = Z_Core::$MC->get($cacheKey);
-		if ($fieldName) {
-			self::$fieldNameCache[$fieldOrFieldID] = $fieldName;
-			return $fieldName;
-		}
-		
-		$sql = "(SELECT fieldName FROM fields WHERE fieldID=?) UNION
-				(SELECT fieldName FROM fields WHERE fieldName=?) LIMIT 1";
-		$fieldName = Zotero_DB::valueQuery($sql, array($fieldOrFieldID, $fieldOrFieldID));
-		
-		self::$fieldNameCache[$fieldOrFieldID] = $fieldName;
-		Z_Core::$MC->set($cacheKey, $fieldName);
-		
-		return $fieldName;
-	}
-	
-	
-	public static function getLocalizedString($itemType, $field, $locale='en-US') {
-		if ($locale != 'en-US') {
-			throw new Exception("Locale not yet supported");
-		}
-		
-		// Fields in the items table are special cases
-		switch ($field) {
-			case 'dateAdded':
-			case 'dateModified':
-			case 'itemType':
-				$fieldName = $field;
-				break;
-			
-			default:
-				// unused currently
-				//var typeName = Zotero.ItemTypes.getName(itemType);
-				$fieldName = self::getName($field);
-		}
-		
-		// TODO: different labels for different item types
-		
-		return self::$localizedFields[$fieldName];
-	}
-	
-	
-	public static function getAll($locale=false) {
-		$sql = "SELECT DISTINCT fieldID AS id, fieldName AS name
-				FROM fields JOIN itemTypeFields USING (fieldID)";
-		// TEMP - skip nsfReviewer fields
-		$sql .= " WHERE fieldID < 10000";
-		$rows = Zotero_DB::query($sql);
-		
-		// TODO: cache
-		
-		if (!$locale) {
-			return $rows;
-		}
-		
-		foreach ($rows as &$row) {
-			$row['localized'] =  self::getLocalizedString(false, $row['id'], $locale);
-		}
-		
-		usort($rows, function ($a, $b) {
-			return strcmp($a["localized"], $b["localized"]);
-		});
-		
-		return $rows;
-	}
-	
-	
-	/**
-	 * Validates field content
-	 *
-	 * @param	string		$field		Field name
-	 * @param	mixed		$value
-	 * @return	bool
-	 */
-	public static function validate($field, $value) {
-		if (empty($field)) {
-			throw new Exception("Field not provided");
-		}
-		
-		switch ($field) {
-			case 'itemTypeID':
-				return is_integer($value);
-		}
-		
-		return true;
-	}
-	
-	
-	public static function isValidForType($fieldID, $itemTypeID) {
-		// Check local cache
-		if (isset(self::$isValidForTypeCache[$itemTypeID][$fieldID])) {
-			return self::$isValidForTypeCache[$itemTypeID][$fieldID];
-		}
-		
-		// Check memcached
-		$cacheKey = "isValidForType_" . $itemTypeID . "_" . $fieldID;
-		$valid = Z_Core::$MC->get($cacheKey);
-		if ($valid !== false) {
-			if (!isset(self::$isValidForTypeCache[$itemTypeID])) {
-				self::$isValidForTypeCache[$itemTypeID] = array();
-			}
-			self::$isValidForTypeCache[$itemTypeID][$fieldID] = !!$valid;
-			return !!$valid;
-		}
-		
-		if (!self::getID($fieldID)) {
-			throw new Exception("Invalid fieldID '$fieldID'");
-		}
-		
-		if (!Zotero_ItemTypes::getID($itemTypeID)) {
-			throw new Exception("Invalid item type id '$itemTypeID'");
-		}
-		
-		$sql = "SELECT COUNT(*) FROM itemTypeFields WHERE itemTypeID=? AND fieldID=?";
-		$valid = !!Zotero_DB::valueQuery($sql, array($itemTypeID, $fieldID));
-		
-		// Store in local cache and memcached
-		if (!isset(self::$isValidForTypeCache[$itemTypeID])) {
-			self::$isValidForTypeCache[$itemTypeID] = array();
-		}
-		self::$isValidForTypeCache[$itemTypeID][$fieldID] = $valid;
-		Z_Core::$MC->set($cacheKey, $valid ? true : 0);
-		
-		return $valid;
-	}
-	
-	
-	public static function getItemTypeFields($itemTypeID) {
-		if (isset(self::$itemTypeFieldsCache[$itemTypeID])) {
-			return self::$itemTypeFieldsCache[$itemTypeID];
-		}
-		
-		$cacheKey = "itemTypeFields_" . $itemTypeID;
-		$fields = Z_Core::$MC->get($cacheKey);
-		if ($fields !== false) {
-			self::$itemTypeFieldsCache[$itemTypeID] = $fields;
-			return $fields;
-		}
-		
-		if (!Zotero_ItemTypes::getID($itemTypeID)) {
-			throw new Exception("Invalid item type id '$itemTypeID'");
-		}
-		
-		$sql = 'SELECT fieldID FROM itemTypeFields WHERE itemTypeID=? ORDER BY orderIndex';
-		$fields = Zotero_DB::columnQuery($sql, $itemTypeID);
-		if (!$fields) {
-			$fields = array();
-		}
-		
-		self::$itemTypeFieldsCache[$itemTypeID] = $fields;
-		Z_Core::$MC->set($cacheKey, $fields);
-		
-		return $fields;
-	}
-	
-	
-	public static function isBaseField($field) {
-		$fieldID = self::getID($field);
-		if (!$fieldID) {
-			throw new Exception("Invalid field '$field'");
-		}
-		
-		if (isset(self::$isBaseFieldCache[$fieldID])) {
-			return self::$isBaseFieldCache[$fieldID];
-		}
-		
-		$cacheKey = "isBaseField_" . $fieldID;
-		$isBase = Z_Core::$MC->get($cacheKey);
-		if ($isBase !== false) {
-			self::$isBaseFieldCache[$fieldID] = !!$isBase;
-			return !!$isBase;
-		}
-		
-		$sql = "SELECT COUNT(*) FROM baseFieldMappings WHERE baseFieldID=?";
-		$isBase = !!Zotero_DB::valueQuery($sql, $fieldID);
-		
-		self::$isBaseFieldCache[$fieldID] = $isBase;
-		// Store in memcached (and store FALSE as 0)
-		Z_Core::$MC->set($cacheKey, $isBase ? true : 0);
-		
-		return $isBase;
-	}
-	
-	
-	public static function isFieldOfBase($field, $baseField) {
-		$fieldID = self::getID($field);
-		if (!$fieldID) {
-			throw new Exception("Invalid field '$field'");
-		}
-		
-		$baseFieldID = self::getID($baseField);
-		if (!$baseFieldID) {
-			throw new Exception("Invalid field '$baseField' for base field");
-		}
-		
-		if ($fieldID == $baseFieldID) {
-			return true;
-		}
-		
-		$typeFields = self::getTypeFieldsFromBase($baseFieldID);
-		return in_array($fieldID, $typeFields);
-	}
-	
-	
-	public static function getBaseMappedFields() {
-		$sql = "SELECT DISTINCT fieldID FROM baseFieldMappings";
-		return Zotero_DB::columnQuery($sql);
-	}
-	
-	
-	/*
-	 * Returns the fieldID of a type-specific field for a given base field
-	 * 		or false if none
-	 *
-	 * Examples:
-	 *
-	 * 'audioRecording' and 'publisher' returns label's fieldID
-	 * 'book' and 'publisher' returns publisher's fieldID
-	 * 'audioRecording' and 'number' returns false
-	 *
-	 * Accepts names or ids
-	 */
-	public static function getFieldIDFromTypeAndBase($itemType, $baseField) {
-		$itemTypeID = Zotero_ItemTypes::getID($itemType);
-		$baseFieldID = self::getID($baseField);
-		
-		// Check local cache
-		if (isset(self::$itemTypeBaseFieldIDCache[$itemTypeID][$baseFieldID])) {
-			return self::$itemTypeBaseFieldIDCache[$itemTypeID][$baseFieldID];
-		}
-		
-		// Check memcached
-		$cacheKey = "itemTypeBaseFieldID_" . $itemTypeID . "_" . $baseFieldID;
-		$fieldID = Z_Core::$MC->get($cacheKey);
-		if ($fieldID !== false) {
-			if (!isset(self::$itemTypeBaseFieldIDCache[$itemTypeID])) {
-				self::$itemTypeBaseFieldIDCache[$itemTypeID] = array();
-			}
-			// FALSE is stored as 0
-			if ($fieldID == 0) {
-				$fieldID = false;
-			}
-			self::$itemTypeBaseFieldIDCache[$itemTypeID][$baseFieldID] = $fieldID;
-			return $fieldID;
-		}
-		
-		if (!$itemTypeID) {
-			throw new Exception("Invalid item type '$itemType'");
-		}
-		
-		if (!$baseFieldID) {
-			throw new Exception("Invalid field '$baseField' for base field");
-		}
-		
-		$sql = "SELECT fieldID FROM baseFieldMappings
-					WHERE itemTypeID=? AND baseFieldID=?
-				UNION
-				SELECT baseFieldID FROM baseFieldMappings
-					WHERE itemTypeID=? AND baseFieldID=?";
-		$fieldID =  Zotero_DB::valueQuery(
-			$sql,
-			array($itemTypeID, $baseFieldID, $itemTypeID, $baseFieldID)
-		);
-		
-		if (!$fieldID) {
-			if (self::isBaseField($baseFieldID) && self::isValidForType($baseFieldID, $itemTypeID)) {
-				$fieldID = $baseFieldID;
-			}
-		}
-		
-		// Store in local cache
-		if (!isset(self::$itemTypeBaseFieldIDCache[$itemTypeID])) {
-			self::$itemTypeBaseFieldIDCache[$itemTypeID] = array();
-		}
-		self::$itemTypeBaseFieldIDCache[$itemTypeID][$baseFieldID] = $fieldID;
-		// Store in memcached (and store FALSE as 0)
-		Z_Core::$MC->set($cacheKey, $fieldID ? $fieldID : 0);
-		
-		return $fieldID;
-	}
-	
-	
-	/*
-	 * Returns the fieldID of the base field for a given type-specific field
-	 * 		or false if none
-	 *
-	 * Examples:
-	 *
-	 * 'audioRecording' and 'label' returns publisher's fieldID
-	 * 'book' and 'publisher' returns publisher's fieldID
-	 * 'audioRecording' and 'runningTime' returns false
-	 *
-	 * Accepts names or ids
-	 */
-	public static function getBaseIDFromTypeAndField($itemType, $typeField) {
-		$itemTypeID = Zotero_ItemTypes::getID($itemType);
-		if (!$itemTypeID) {
-			throw new Exception("Invalid item type '$itemType'");
-		}
-		
-		$typeFieldID = self::getID($typeField);
-		if (!$typeFieldID) {
-			throw new Exception("Invalid field '$typeField'");
-		}
-		
-		if (!self::isValidForType($typeFieldID, $itemTypeID)) {
-			throw new Exception("'$typeField' is not a valid field for item type '$itemType'");
-		}
-		
-		// If typeField is already a base field, just return that
-		if (self::isBaseField($typeFieldID)) {
-			return $typeFieldID;
-		}
-		
-		return Zotero_DB::valueQuery("SELECT baseFieldID FROM baseFieldMappings
-			WHERE itemTypeID=? AND fieldID=?", array($itemTypeID, $typeFieldID));
-	}
-	
-	
-	/*
-	 * Returns an array of fieldIDs associated with a given base field
-	 *
-	 * e.g. 'publisher' returns fieldIDs for [university, studio, label, network]
-	 */
-	public static function getTypeFieldsFromBase($baseField, $asNames=false) {
-		$baseFieldID = self::getID($baseField);
-		if (!$baseFieldID) {
-			throw new Exception("Invalid base field '$baseField'");
-		}
-		
-		if ($asNames) {
-			if (isset(self::$typeFieldNamesByBaseCache[$baseFieldID])) {
-				return self::$typeFieldNamesByBaseCache[$baseFieldID];
-			}
-			
-			$cacheKey = "itemTypeFieldNamesByBase_" . $baseFieldID;
-			$fieldNames = Z_Core::$MC->get($cacheKey);
-			if ($fieldNames) {
-				self::$typeFieldNamesByBaseCache[$baseFieldID] = $fieldNames;
-				return $fieldNames;
-			}
-			
-			$sql = "SELECT fieldName FROM fields WHERE fieldID IN (
-				SELECT fieldID FROM baseFieldMappings
-				WHERE baseFieldID=?)";
-			$fieldNames = Zotero_DB::columnQuery($sql, $baseFieldID);
-			if (!$fieldNames) {
-				$fieldNames = array();
-			}
-			
-			self::$typeFieldNamesByBaseCache[$baseFieldID] = $fieldNames;
-			Z_Core::$MC->set($cacheKey, $fieldNames);
-			
-			return $fieldNames;
-		}
-		
-		// TEMP
-		if ($baseFieldID==14) {
-			return array(96,52,100,10008);
-		}
-		
-		if (isset(self::$typeFieldIDsByBaseCache[$baseFieldID])) {
-			return self::$typeFieldIDsByBaseCache[$baseFieldID];
-		}
-		
-		$cacheKey = "itemTypeFieldIDsByBase_" . $baseFieldID;
-		$fieldIDs = Z_Core::$MC->get($cacheKey);
-		if ($fieldIDs) {
-			self::$typeFieldIDsByBaseCache[$baseFieldID] = $fieldIDs;
-			return $fieldIDs;
-		}
-		
-		$sql = "SELECT DISTINCT fieldID FROM baseFieldMappings WHERE baseFieldID=?";
-		$fieldIDs = Zotero_DB::columnQuery($sql, $baseFieldID);
-		if (!$fieldIDs) {
-			$fieldIDs = array();
-		}
-		
-		self::$typeFieldIDsByBaseCache[$baseFieldID] = $fieldIDs;
-		Z_Core::$MC->set($cacheKey, $fieldIDs);
-		
-		return $fieldIDs;
-	}
-	
-	
-	public static function isCustomField($fieldID) {
-		if (isset(self::$customFieldCheck)) {
-			return self::$customFieldCheck;
-		}
-		
-		$sql = "SELECT custom FROM fields WHERE fieldID=?";
-		$isCustom = Zotero_DB::valueQuery($sql, $fieldID);
-		if ($isCustom === false) {
-			throw new Exception("Invalid fieldID '$fieldID'");
-		}
-		
-		self::$customFieldCheck[$fieldID] = !!$isCustom;
-		
-		return !!$isCustom;
-	}
-	
-	
-	public static function addCustomField($name) {
-		if (self::getID($name)) {
-			trigger_error("Field '$name' already exists", E_USER_ERROR);
-		}
-		
-		if (!preg_match('/^[a-z][^\s0-9]+$/', $name)) {
-			trigger_error("Invalid field name '$name'", E_USER_ERROR);
-		}
-		
-		// TODO: make sure user hasn't added too many already
-		
-		trigger_error("Unimplemented", E_USER_ERROR);
-		// TODO: add to cache
-		
-		Zotero_DB::beginTransaction();
-		
-		$sql = "SELECT NEXT_ID(fieldID) FROM fields";
-		$fieldID = Zotero_DB::valueQuery($sql);
-		
-		$sql = "INSERT INTO fields (?, ?, ?)";
-		Zotero_DB::query($sql, array($fieldID, $name, 1));
-		
-		Zotero_DB::commit();
-		
-		return $fieldID;
-	}
-}
-?>
diff --git a/model/ItemTypes.inc.php b/model/ItemTypes.inc.php
deleted file mode 100644
index a576e7e3..00000000
--- a/model/ItemTypes.inc.php
+++ /dev/null
@@ -1,272 +0,0 @@
-.
-    
-    ***** END LICENSE BLOCK *****
-*/
-
-class Zotero_ItemTypes {
-	private static $typeIDs = array();
-	private static $typeNames = array();
-	private static $customTypeCheck = array();
-	
-	private static $localizedStrings = array(
-		"note"					=> "Note",
-		"attachment"			=> "Attachment",
-		"book"					=> "Book",
-		"bookSection"			=> "Book Section",
-		"journalArticle" 		=> "Journal Article",
-		"magazineArticle" 		=> "Magazine Article",
-		"newspaperArticle"		=> "Newspaper Article",
-		"thesis"		 		=> "Thesis",
-		"letter" 				=> "Letter",
-		"manuscript" 			=> "Manuscript",
-		"interview" 			=> "Interview",
-		"film" 					=> "Film",
-		"artwork" 				=> "Artwork",
-		"webpage" 				=> "Web Page",
-		"report"				=> "Report",
-		"bill"					=> "Bill",
-		"case"					=> "Case",
-		"hearing"				=> "Hearing",
-		"patent"				=> "Patent",
-		"statute"				=> "Statute",
-		"email"					=> "E-mail",
-		"map"					=> "Map",
-		"blogPost"				=> "Blog Post",
-		"instantMessage"		=> "Instant Message",
-		"forumPost"				=> "Forum Post",
-		"audioRecording"		=> "Audio Recording",
-		"presentation"			=> "Presentation",
-		"videoRecording"		=> "Video Recording",
-		"tvBroadcast"			=> "TV Broadcast",
-		"radioBroadcast"		=> "Radio Broadcast",
-		"podcast"				=> "Podcast",
-		"computerProgram"		=> "Computer Program",
-		"conferencePaper"		=> "Conference Paper",
-		"document"				=> "Document",
-		"encyclopediaArticle"	=> "Encyclopedia Article",
-		"dictionaryEntry"		=> "Dictionary Entry",
-		"nsfReviewer"			=> "NSF Reviewer"
-	);
-	
-	public static function getID($typeOrTypeID) {
-		if (!$typeOrTypeID) {
-			throw new Exception("An item type id or name must be provided");
-		}
-		
-		if (isset(self::$typeIDs[$typeOrTypeID])) {
-			return self::$typeIDs[$typeOrTypeID];
-		}
-		
-		$cacheKey = "itemTypeID_" . $typeOrTypeID;
-		$typeID = Z_Core::$MC->get($cacheKey);
-		if ($typeID) {
-			// casts are temporary until memcached reload
-			self::$typeIDs[$typeOrTypeID] = (int) $typeID;
-			return (int) $typeID;
-		}
-		
-		$sql = "(SELECT itemTypeID FROM itemTypes WHERE itemTypeID=?) UNION
-				(SELECT itemTypeID FROM itemTypes WHERE itemTypeName=?) LIMIT 1";
-		$typeID = Zotero_DB::valueQuery($sql, array($typeOrTypeID, $typeOrTypeID));
-		
-		self::$typeIDs[$typeOrTypeID] = $typeID ? (int) $typeID : false;
-		Z_Core::$MC->set($cacheKey, (int) $typeID);
-		
-		return $typeID ? (int) $typeID : false;
-	}
-	
-	
-	public static function getName($typeOrTypeID) {
-		if (!$typeOrTypeID) {
-			throw new Exception("An item type id or name must be provided");
-		}
-		
-		if (isset(self::$typeNames[$typeOrTypeID])) {
-			return self::$typeNames[$typeOrTypeID];
-		}
-		
-		$cacheKey = "itemTypeName_" . $typeOrTypeID;
-		$typeName = Z_Core::$MC->get($cacheKey);
-		if ($typeName) {
-			self::$typeNames[$typeOrTypeID] = $typeName;
-			return $typeName;
-		}
-		
-		$sql = "(SELECT itemTypeName FROM itemTypes WHERE itemTypeID=?) UNION
-				(SELECT itemTypeName FROM itemTypes WHERE itemTypeName=?) LIMIT 1";
-		$typeName = Zotero_DB::valueQuery($sql, array($typeOrTypeID, $typeOrTypeID));
-		
-		self::$typeNames[$typeOrTypeID] = $typeName;
-		Z_Core::$MC->set($cacheKey, $typeName);
-		
-		return $typeName;
-	}
-	
-	
-	public static function getLocalizedString($typeOrTypeID, $locale='en-US') {
-		if ($locale != 'en-US') {
-			throw new Exception("Locale not yet supported");
-		}
-		
-		$itemType = self::getName($typeOrTypeID);
-		return self::$localizedStrings[$itemType];
-	}
-	
-	
-	public static function getAll($locale=false) {
-		$sql = "SELECT itemTypeID AS id, itemTypeName AS name FROM itemTypes";
-		// TEMP - skip nsfReviewer and attachment
-		$sql .= " WHERE itemTypeID NOT IN (14,10001)";
-		$rows = Zotero_DB::query($sql);
-		
-		// TODO: cache
-		
-		if (!$locale) {
-			return $rows;
-		}
-		
-		foreach ($rows as &$row) {
-			$row['localized'] =  self::getLocalizedString($row['id'], $locale);
-		}
-		
-		usort($rows, function ($a, $b) {
-			return strcmp($a["localized"], $b["localized"]);
-		});
-		
-		return $rows;
-	}
-	
-	
-	public static function getImageSrc($itemType, $linkMode=false, $mimeType=false) {
-		$prefix = "/static/i/itemType/treeitem";
-		
-		if ($itemType == 'attachment') {
-			if ($mimeType == 'application/pdf') {
-				$itemType .= '-pdf';
-			}
-			else {
-				switch ($linkMode) {
-					case 0:
-						$itemType .= '-file';
-						break;
-					
-					case 1:
-						$itemType .= '-file';
-						break;
-					
-					case 2:
-						$itemType .= '-snapshot';
-						break;
-					
-					case 3:
-						$itemType .= '-web-link';
-						break;
-				}
-			}
-		}
-		
-		// DEBUG: only have icons for some types so far
-		switch ($itemType) {
-			case 'attachment-file':
-			case 'attachment-link':
-			case 'attachment-snapshot':
-			case 'attachment-web-link':
-			case 'attachment-pdf':
-			case 'artwork':
-			case 'audioRecording':
-			case 'blogPost':
-			case 'book':
-			case 'bookSection':
-			case 'computerProgram':
-			case 'conferencePaper':
-			case 'email':
-			case 'film':
-			case 'forumPost':
-			case 'interview':
-			case 'journalArticle':
-			case 'letter':
-			case 'magazineArticle':
-			case 'manuscript':
-			case 'map':
-			case 'newspaperArticle':
-			case 'note':
-			case 'podcast':
-			case 'radioBroadcast':
-			case 'report':
-			case 'thesis':
-			case 'tvBroadcast':
-			case 'videoRecording':
-			case 'webpage':
-				return $prefix . '-' . $itemType . ".png";
-		}
-		
-		return $prefix . ".png";
-	}
-	
-	
-	public static function isCustomType($itemTypeID) {
-		if (isset(self::$customTypeCheck)) {
-			return self::$customTypeCheck;
-		}
-		
-		$sql = "SELECT custom FROM itemTypes WHERE itemTypeID=?";
-		$isCustom = Zotero_DB::valueQuery($sql, $itemTypeID);
-		if ($isCustom === false) {
-			throw new Exception("Invalid itemTypeID '$itemTypeID'");
-		}
-		
-		self::$customTypesCheck[$itemTypeID] = !!$isCustom;
-		
-		return !!$isCustom;
-	}
-	
-	
-	public static function addCustomType($name) {
-		if (self::getID($name)) {
-			throw new Exception("Item type '$name' already exists");
-		}
-		
-		if (!preg_match('/^[a-z][^\s0-9]+$/', $name)) {
-			throw new Exception("Invalid item type name '$name'");
-		}
-		
-		// TODO: make sure user hasn't added too many already
-		
-		throw new Exception("Unimplemented");
-		// TODO: add to cache
-		
-		Zotero_DB::beginTransaction();
-		
-		$sql = "SELECT NEXT_ID(itemTypeID) FROM itemTypes";
-		$itemTypeID = Zotero_DB::valueQuery($sql);
-		
-		$sql = "INSERT INTO itemTypes (?, ?, ?)";
-		Zotero_DB::query($sql, array($itemTypeID, $name, 1));
-		
-		Zotero_DB::commit();
-		
-		return $itemTypeID;
-	}
-}
-?>
diff --git a/model/Items.inc.php b/model/Items.inc.php
deleted file mode 100644
index cc30995d..00000000
--- a/model/Items.inc.php
+++ /dev/null
@@ -1,2658 +0,0 @@
-.
-    
-    ***** 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;
-	
-	/**
-	 *
-	 * 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, $params=array(), $includeTrashed=false, Zotero_Permissions $permissions=null) {
-		$rnd = "_" . uniqid($libraryID . "_");
-		
-		$results = array('results' => array(), 'total' => 0);
-		
-		$shardID = Zotero_Shards::getByLibraryID($libraryID);
-		
-		$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';
-		$parentItemSort = !empty($params['sort'])
-			&& in_array($params['sort'], ['itemType', 'dateAdded', 'dateModified', 'serverDateModified', 'addedBy']);
-		
-		$sql = "SELECT SQL_CALC_FOUND_ROWS DISTINCT ";
-		
-		// In /top mode, use the parent item's values for most joins
-		if ($onlyTopLevel) {
-			$itemIDSelector = "COALESCE(IA.sourceItemID, INo.sourceItemID, I.itemID)";
-			$itemKeySelector = "COALESCE(IP.key, I.key)";
-			$itemVersionSelector = "COALESCE(IP.version, I.version)";
-			$itemTypeIDSelector = "COALESCE(IP.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 parent itemID
-		if ($onlyTopLevel) {
-			$sql .= "LEFT JOIN itemAttachments IA ON (IA.itemID=I.itemID) ";
-		}
-		
-		// For /top, we need the parent itemID; for 'q' we need the note; for sorting by title,
-		// we need the note title
-		if ($onlyTopLevel || !empty($params['q']) || $titleSort) {
-			$sql .= "LEFT JOIN itemNotes INo ON (INo.itemID=I.itemID) ";
-		}
-		
-		// For some /top requests, pull in the parent item's items row
-		if ($onlyTopLevel && ($params['format'] == 'keys' || $params['format'] == 'versions' || $parentItemSort)) {
-			$sql .= "LEFT JOIN items IP ON ($itemIDSelector=IP.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) {
-				$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::getUsername($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) {
-				$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'])) {
-			$sql .= "AND (";
-			
-			$sql .= "IDT.value LIKE ? ";
-			$sqlParams[] = '%' . $params['q'] . '%';
-			
-			$sql .= "OR INo.title LIKE ? ";
-			$sqlParams[] = '%' . $params['q'] . '%';
-			
-			$sql .= "OR TRIM(CONCAT(firstName, ' ', lastName)) LIKE ? ";
-			$sqlParams[] = '%' . $params['q'] . '%';
-			
-			$sql .= "OR SUBSTR(IDD.value, 1, 4) = ?";
-			$sqlParams[] = $params['q'];
-			
-			// Full-text search
-			if ($params['qmode'] == 'everything') {
-				$ftKeys = Zotero_FullText::searchInLibrary($libraryID, $params['q']);
-				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_fill(0, sizeOf($itemIDs), '?'))
-					. ") ";
-			$sqlParams = array_merge($sqlParams, $itemIDs);
-		}
-		
-		if ($itemKeys) {
-			$sql .= "AND I.key IN ("
-					. implode(', ', array_fill(0, sizeOf($itemKeys), '?'))
-					. ") ";
-			$sqlParams = array_merge($sqlParams, $itemKeys);
-		}
-		
-		$sql .= "ORDER BY ";
-		
-		if (!empty($params['sort'])) {
-			switch ($params['sort']) {
-				case 'dateAdded':
-				case 'dateModified':
-				case 'serverDateModified':
-					if ($onlyTopLevel) {
-						$orderSQL = "IP." . $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 ? "IP" : "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=I.itemID 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') {
-				$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);
-		}
-	}
-	
-	
-	public static function getDataValuesFromXML(DOMDocument $doc) {
-		$xpath = new DOMXPath($doc);
-		$fields = $xpath->evaluate('//items/item/field');
-		$vals = array();
-		foreach ($fields as $f) {
-			$vals[] = $f->firstChild->nodeValue;
-		}
-		$vals = array_unique($vals);
-		return $vals;
-	}
-	
-	
-	public static function getLongDataValueFromXML(DOMDocument $doc) {
-		$xpath = new DOMXPath($doc);
-		$fields = $xpath->evaluate('//items/item/field[string-length(text()) > ' . self::$maxDataValueLength . ']');
-		return $fields->length ? $fields->item(0) : false;
-	}
-	
-	
-	/**
-	 * 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 item
-	 *
-	 * @param	object				$item		Zotero_Item object
-	 * @param	array				$data
-	 * @return	SimpleXMLElement					Item data as SimpleXML element
-	 */
-	public static function convertItemToXML(Zotero_Item $item, $data=array()) {
-		$t = microtime(true);
-		
-		// Check cache for all items except imported attachments,
-		// which don't have their versions updated when the client
-		// updates their file metadata
-		if (!$item->isImportedAttachment()) {
-			$cacheVersion = 1;
-			$cacheKey = "syncXMLItem_" . $item->libraryID . "/" . $item->id . "_"
-				. $item->version
-				. "_" . md5(json_encode($data))
-				// For code-based changes
-				. "_" . $cacheVersion
-				// For data-based changes
-				. (isset(Z_CONFIG::$CACHE_VERSION_SYNC_XML_ITEM)
-					? "_" . Z_CONFIG::$CACHE_VERSION_SYNC_XML_ITEM
-					: "");
-			$xmlstr = Z_Core::$MC->get($cacheKey);
-		}
-		else {
-			$cacheKey = false;
-			$xmlstr = false;
-		}
-		if ($xmlstr) {
-			$xml = new SimpleXMLElement($xmlstr);
-			
-			StatsD::timing("api.items.itemToSyncXML.cached", (microtime(true) - $t) * 1000);
-			StatsD::increment("memcached.items.itemToSyncXML.hit");
-			
-			// Skip the cache every 10 times for now, to ensure cache sanity
-			if (Z_Core::probability(10)) {
-				//$xmlstr = $xml->saveXML();
-			}
-			else {
-				Z_Core::debug("Using cached sync XML item");
-				return $xml;
-			}
-		}
-		
-		$xml = new SimpleXMLElement('');
-		
-		// Primary fields
-		foreach (self::$primaryFields as $field) {
-			switch ($field) {
-				case 'id':
-				case 'serverDateModified':
-				case 'version':
-					continue (2);
-				
-				case 'itemTypeID':
-					$xmlField = 'itemType';
-					$xmlValue = Zotero_ItemTypes::getName($item->$field);
-					break;
-				
-				default:
-					$xmlField = $field;
-					$xmlValue = $item->$field;
-			}
-			
-			$xml[$xmlField] = $xmlValue;
-		}
-		
-		// Item data
-		$itemTypeID = $item->itemTypeID;
-		$fieldIDs = $item->getUsedFields();
-		foreach ($fieldIDs as $fieldID) {
-			$val = $item->getField($fieldID);
-			if ($val == '') {
-				continue;
-			}
-			$f = $xml->addChild('field', htmlspecialchars($val));
-			$fieldName = Zotero_ItemFields::getName($fieldID);
-			// Special handling for renamed computerProgram 'version' field
-			if ($itemTypeID == 32 && $fieldName == 'versionNumber') {
-				$fieldName = 'version';
-			}
-			$f['name'] = htmlspecialchars($fieldName);
-		}
-		
-		// Deleted item flag
-		if ($item->deleted) {
-			$xml['deleted'] = '1';
-		}
-		
-		if ($item->isNote() || $item->isAttachment()) {
-			$sourceItemID = $item->getSource();
-			if ($sourceItemID) {
-				$sourceItem = Zotero_Items::get($item->libraryID, $sourceItemID);
-				if (!$sourceItem) {
-					throw new Exception("Parent item $sourceItemID not found");
-				}
-				$xml['sourceItem'] = $sourceItem->key;
-			}
-		}
-		
-		// Group modification info
-		$createdByUserID = null;
-		$lastModifiedByUserID = null;
-		switch (Zotero_Libraries::getType($item->libraryID)) {
-			case 'group':
-				$createdByUserID = $item->createdByUserID;
-				$lastModifiedByUserID = $item->lastModifiedByUserID;
-				break;
-		}
-		if ($createdByUserID) {
-			$xml['createdByUserID'] = $createdByUserID;
-		}
-		if ($lastModifiedByUserID) {
-			$xml['lastModifiedByUserID'] = $lastModifiedByUserID;
-		}
-		
-		if ($item->isAttachment()) {
-			$linkMode = $item->attachmentLinkMode;
-			$xml['linkMode'] = Zotero_Attachments::linkModeNameToNumber($linkMode);
-			$xml['mimeType'] = $item->attachmentMIMEType;
-			if ($item->attachmentCharset) {
-				$xml['charset'] = $item->attachmentCharset;
-			}
-			
-			$storageModTime = $item->attachmentStorageModTime;
-			if ($storageModTime) {
-				$xml['storageModTime'] = $storageModTime;
-			}
-			
-			$storageHash = $item->attachmentStorageHash;
-			if ($storageHash) {
-				$xml['storageHash'] = $storageHash;
-			}
-			
-			if ($linkMode != 'linked_url') {
-				$xml->addChild('path', htmlspecialchars($item->attachmentPath));
-			}
-		}
-		
-		// Note
-		if ($item->isNote() || $item->isAttachment()) {
-			// Get htmlspecialchars'ed note
-			$note = $item->getNote(false, true);
-			if ($note !== '') {
-				$xml->addChild('note', $note);
-			}
-			else if ($item->isNote()) {
-				$xml->addChild('note', '');
-			}
-		}
-		
-		// Creators
-		$creators = $item->getCreators();
-		if ($creators) {
-			foreach ($creators as $index => $creator) {
-				$c = $xml->addChild('creator');
-				$c['key'] = $creator['ref']->key;
-				$c['creatorType'] = htmlspecialchars(
-					Zotero_CreatorTypes::getName($creator['creatorTypeID'])
-				);
-				$c['index'] = $index;
-				if (empty($data['updatedCreators']) ||
-						!in_array($creator['ref']->id, $data['updatedCreators'])) {
-					$cNode = dom_import_simplexml($c);
-					$creatorXML = Zotero_Creators::convertCreatorToXML($creator['ref'], $cNode->ownerDocument);
-					$cNode->appendChild($creatorXML);
-				}
-			}
-		}
-		
-		// Related items
-		$relatedKeys = $item->relatedItems;
-		$keys = array();
-		foreach ($relatedKeys as $relatedKey) {
-			if (Zotero_Items::getByLibraryAndKey($item->libraryID, $relatedKey)) {
-				$keys[] = $relatedKey;
-			}
-		}
-		if ($keys) {
-			$xml->related = implode(' ', $keys);
-		}
-		
-		if ($xmlstr) {
-			$uncached = $xml->saveXML();
-			if ($xmlstr != $uncached) {
-				error_log("Cached sync XML item does not match");
-				error_log("  Cached: " . $xmlstr);
-				error_log("Uncached: " . $uncached);
-			}
-		}
-		else {
-			$xmlstr = $xml->saveXML();
-			if ($cacheKey) {
-				Z_Core::$MC->set($cacheKey, $xmlstr, 3600); // 1 hour for now
-			}
-			StatsD::timing("api.items.itemToSyncXML.uncached", (microtime(true) - $t) * 1000);
-			StatsD::increment("memcached.items.itemToSyncXML.miss");
-		}
-		
-		return $xml;
-	}
-	
-	
-	/**
-	 * 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 = 3;
-		$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::getUsername($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::getUsername($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 ($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": "http://..."
-	 * }
-	 *
-	 * Item selection for multi-item results:
-	 *
-	 * {
-	 *   "url": "http://...",
-	 *   "token": "<token>"
-	 *   "items": {
-	 *     "0": "Item 1 Title",
-	 *     "3": "Item 2 Title"
-	 *   }
-	 * }
-	 *
-	 * Returns an array of keys of added items (like updateMultipleFromJSON) or an object
-	 * with a 'select' property containing an array of titles for multi-item results
-	 */
-	public static function addFromURL($json, $requestParams, $libraryID, $userID,
-			Zotero_Permissions $permissions, $translationToken) {
-		if (!$translationToken) {
-			throw new Exception("Translation token not provided");
-		}
-		
-		self::validateJSONURL($json, $requestParams);
-		
-		$cacheKey = 'addFromURLKeyMappings_' . md5($json->url . $translationToken);
-		
-		// Replace numeric keys with URLs for selected items
-		if (isset($json->items) && $requestParams['v'] >= 2) {
-			$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;
-		}
-		
-		$response = Zotero_Translate::doWeb(
-			$json->url,
-			$translationToken,
-			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?
-				}
-				
-				$response = $items;
-			}
-			// APIv2 (was this ever used? it's possible the bookmarklet used v1 and we never publicized
-			// this for v2)
-			else if ($requestParams['v'] == 2) {
-				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?
-				}
-			}
-			// APIv1
-			else {
-				for ($i = 0, $len = sizeOf($items); $i < $len; $i++) {
-					unset($items[$i]->key);
-					unset($items[$i]->version);
-					unset($items[$i]->itemKey);
-					unset($items[$i]->itemVersion);
-				}
-			}
-			
-			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)) {
-			// Replace URLs with numeric keys for found items
-			if ($requestParams['v'] >= 2) {
-				$keyMappings = [];
-				$newItems = new stdClass;
-				$number = 0;
-				foreach ($response->select as $url => $title) {
-					$keyMappings[$number] = $url;
-					$newItems->$number = $title;
-					$number++;
-				}
-				Z_Core::$MC->set($cacheKey, $keyMappings, 600);
-				$response->select = $newItems;
-			}
-			return $response;
-		}
-		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;
-				
-				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;
-					}
-					$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':
-					$item->attachmentStorageHash = $val;
-					break;
-					
-				case 'mtime':
-					$item->attachmentStorageModTime = $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);
-		}
-		
-		$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;
-						}
-						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;
-						}
-						$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;
-	}
-	
-	
-	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;
-			}
-		}
-		else {
-			if (isset($json->parentItem) && $json->parentItem !== false) {
-				$isChild = true;
-			}
-		}
-		
-		if ($partialUpdate) {
-			$requiredProps = [];
-		}
-		else if (isset($json->itemType) && $json->itemType == "attachment") {
-			$requiredProps = array('linkMode', 'tags');
-		}
-		else if (isset($json->itemType) && $json->itemType == "attachment") {
-			$requiredProps = array('tags');
-		}
-		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 (!Zotero_ID::isValidKey($val) && $val !== false) {
-						throw new Exception("'$key' must be a valid item key or false", Z_ERROR_INVALID_INPUT);
-					}
-					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':
-								break;
-							
-							default:
-								throw new Exception("Child item must be note or attachment", 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);
-					}
-					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':
-					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) {
-								throw new Exception("'$key' is valid only for imported attachment items", 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 (($key == 'mtime' || $key == 'md5') && $libraryType == 'group') {
-						if (($item && $item->$propName !== $val) || (!$item && $val !== null && $val !== "")) {
-							throw new Exception("Cannot change '$key' directly in group library", Z_ERROR_INVALID_INPUT);
-						}
-					}
-					else 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;
-				
-				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-dd]' format or 'CURRENT_TIMESTAMP' ($val)", Z_ERROR_INVALID_INPUT);
-					}
-					break;
-				
-				case 'dateAdded':
-					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-dd' format", Z_ERROR_INVALID_INPUT);
-					}
-					
-					if (!$isNew) {
-						// Convert ISO date to SQL date for equality comparison
-						if (Zotero_Date::isISO8601($val)) {
-							$val = Zotero_Date::iso8601ToSQL($val);
-						}
-						// Don't allow dateAdded to change
-						if ($val != $item->$key) {
-							// If passed dateAdded is exactly one hour or one day off, assume it's from
-							// a DST bug we haven't yet tracked down
-							// (https://github.com/zotero/zotero/issues/1201) and ignore it
-							$absTimeDiff = abs(strtotime($val) - strtotime($item->$key));
-							if ($absTimeDiff == 3600 || $absTimeDiff == 86400
-									// Allow for Quick Start Guide items from <=4.0
-									|| $item->key == 'ABCD2345' || $item->key == 'ABCD3456') {
-								$json->$key = $item->$key;
-							}
-							else {
-								throw new Exception("'$key' cannot be modified for existing items", Z_ERROR_INVALID_INPUT);
-							}
-						}
-					}
-					break;
-				
-				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-dd' 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($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();
diff --git a/model/Key.inc.php b/model/Key.inc.php
deleted file mode 100644
index 0e37f210..00000000
--- a/model/Key.inc.php
+++ /dev/null
@@ -1,600 +0,0 @@
-<?
-/*
-    ***** 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_Key {
-	private $id;
-	private $key;
-	private $userID;
-	private $name;
-	private $dateAdded;
-	private $lastUsed;
-	private $permissions = array();
-	
-	private $loaded = false;
-	private $changed = array();
-	private $erased = false;
-	
-	
-	public function __get($field) {
-		if ($this->erased) {
-			throw new Exception("Cannot access field '$field' of deleted key $this->id");
-		}
-		
-		if (($this->id || $this->key) && !$this->loaded) {
-			$this->load();
-		}
-		
-		switch ($field) {
-			case 'id':
-			case 'key':
-			case 'userID':
-			case 'name':
-			case 'dateAdded':
-				break;
-			
-			default:
-				throw new Exception("Invalid key field '$field'");
-		}
-		
-		return $this->$field;
-	}
-	
-	
-	public function __set($field, $value) {
-		switch ($field) {
-			// Set id and libraryID without loading
-			case 'id':
-			case 'key':
-				if ($this->loaded) {
-					throw new Exception("Cannot set $field after key is already loaded");
-				}
-				$this->$field = $value;
-				return;
-			
-			case 'userID':
-			case 'name':
-				break;
-			
-			default:
-				throw new Exception("Invalid key field '$field'");
-		}
-		
-		if ($this->id || $this->key) {
-			if (!$this->loaded) {
-				$this->load();
-			}
-		}
-		else {
-			$this->loaded = true;
-		}
-		
-		if ($this->$field == $value) {
-			Z_Core::debug("Key $this->id $field value ($value) has not changed", 4);
-			return;
-		}
-		$this->$field = $value;
-		$this->changed[$field] = true;
-	}
-	
-	
-	public function getPermissions() {
-		if ($this->erased) {
-			throw new Exception("Cannot access permissions of deleted key $this->id");
-		}
-		
-		if (($this->id || $this->key) && !$this->loaded) {
-			$this->load();
-		}
-		
-		$permissions = new Zotero_Permissions($this->userID);
-		foreach ($this->permissions as $libraryID=>$p) {
-			foreach ($p as $key=>$val) {
-				$permissions->setPermission($libraryID, $key, $val);
-			}
-		}
-		return $permissions;
-	}
-	
-	
-	/*public function getPermission($libraryID, $permission) {
-		if ($this->erased) {
-			throw new Exception("Cannot access permission of deleted key $this->id");
-		}
-		
-		if (($this->id || $this->key) && !$this->loaded) {
-			$this->load();
-		}
-		
-		return $this->permissions[$libraryID][$permission];
-	}*/
-	
-	
-	/**
-	 * Examples:
-	 *
-	 * $keyObj->setPermission(12345, 'library', true);
-	 * $keyObj->setPermission(12345, 'notes', true);
-	 * $keyObj->setPermission(12345, 'files', true);
-	 * $keyObj->setPermission(12345, 'write', true);
-	 * $keyObj->setPermission(0, 'group', true);
-	 * $keyObj->setPermission(0, 'write', true);
-	 */
-	public function setPermission($libraryID, $permission, $enabled) {
-		if ($this->id || $this->key) {
-			if (!$this->loaded) {
-				$this->load();
-			}
-		}
-		else {
-			$this->loaded = true;
-		}
-		
-		$enabled = !!$enabled;
-		
-		// libraryID=0 is a special case for all-group access
-		if ($libraryID === 0) {
-			// Convert 'group' to 'library'
-			if ($permission == 'group') {
-				$permission = 'library';
-			}
-			else if ($permission == 'write') {}
-			else {
-				throw new Exception("libraryID 0 is valid only with permission 'group'");
-			}
-		}
-		else if ($permission == 'group') {
-			throw new Exception("'group' permission is valid only with libraryID 0");
-		}
-		else if (!$libraryID) {
-			throw new Exception("libraryID not set");
-		}
-		
-		switch ($permission) {
-			case 'library':
-			case 'notes':
-			case 'files':
-			case 'write':
-				break;
-			
-			default:
-				throw new Exception("Invalid key permissions field '$permission'");
-		}
-		
-		$this->permissions[$libraryID][$permission] = $enabled;
-		$this->changed['permissions'][$libraryID][$permission] = true;
-	}
-	
-	
-	
-	public function save() {
-		if (!$this->loaded) {
-			Z_Core::debug("Not saving unloaded key $this->id");
-			return;
-		}
-		
-		if (!$this->userID) {
-			throw new Exception("Cannot save key without userID");
-		}
-		
-		if (!$this->name) {
-			throw new Exception("Cannot save key without name");
-		}
-		
-		if (strlen($this->name) > 255) {
-			throw new Exception("Key name too long", Z_ERROR_KEY_NAME_TOO_LONG);
-		}
-		
-		Zotero_DB::beginTransaction();
-		
-		if (!$this->key) {
-			$isNew = true;
-			$this->key = Zotero_Keys::generate();
-		}
-		else {
-			$isNew = false;
-		}
-		
-		$fields = array(
-			'key',
-			'userID',
-			'name'
-		);
-		
-		$sql = "INSERT INTO `keys` (keyID, `key`, userID, name) VALUES (?, ?, ?, ?)";
-		$params = array($this->id);
-		foreach ($fields as $field) {
-			$params[] = $this->$field;
-		}
-		$sql .= " ON DUPLICATE KEY UPDATE ";
-		$q = array();
-		foreach ($fields as $field) {
-			$q[] = "`$field`=?";
-			$params[] = $this->$field;
-		}
-		$sql .= implode(", ", $q);
-		$insertID = Zotero_DB::query($sql, $params);
-		
-		if (!$this->id) {
-			if (!$insertID) {
-				throw new Exception("Key id not available after INSERT");
-			}
-			$this->id = $insertID;
-		}
-		
-		if (!$insertID) {
-			$sql = "SELECT * FROM keyPermissions WHERE keyID=?";
-			$oldRows = Zotero_DB::query($sql, $this->id);
-		}
-		$oldPermissions = [];
-		$newPermissions = [];
-		$librariesToAdd = [];
-		$librariesToRemove = [];
-		
-		// Massage rows into permissions format
-		if (!$isNew && isset($oldRows)) {
-			foreach ($oldRows as $row) {
-				$oldPermissions[$row['libraryID']][$row['permission']] = !!$row['granted'];
-			}
-		}
-		
-		// Delete existing permissions
-		$sql = "DELETE FROM keyPermissions WHERE keyID=?";
-		Zotero_DB::query($sql, $this->id);
-		
-		if (isset($this->changed['permissions'])) {
-			foreach ($this->changed['permissions'] as $libraryID=>$p) {
-				foreach ($p as $permission=>$changed) {
-					$enabled = $this->permissions[$libraryID][$permission];
-					if (!$enabled) {
-						continue;
-					}
-					
-					$sql = "INSERT INTO keyPermissions VALUES (?, ?, ?, ?)";
-					// TODO: support negative permissions
-					Zotero_DB::query($sql, array($this->id, $libraryID, $permission, 1));
-					
-					$newPermissions[$libraryID][$permission] = true;
-				}
-			}
-		}
-		
-		$this->permissions = $newPermissions;
-		
-		// Send notifications for added and removed API key – library pairs
-		if (!$isNew) {
-			$librariesToAdd = $this->permissionsDiff(
-				$oldPermissions, $newPermissions, $this->userID
-			);
-			$librariesToRemove = $this->permissionsDiff(
-				$newPermissions, $oldPermissions, $this->userID
-			);
-			if ($librariesToAdd) {
-				Zotero_Notifier::trigger(
-					'add',
-					'apikey-library',
-					array_map(function ($libraryID) {
-						return $this->id . "-" . $libraryID;
-					}, array_unique($librariesToAdd))
-				);
-			}
-			if ($librariesToRemove) {
-				Zotero_Notifier::trigger(
-					'remove',
-					'apikey-library',
-					array_map(function ($libraryID) {
-						return $this->id . "-" . $libraryID;
-					}, array_unique($librariesToRemove))
-				);
-			}
-		}
-		
-		Zotero_DB::commit();
-		
-		$this->load();
-		
-		return $this->id;
-	}
-	
-	
-	/**
-	 * Calculate the difference between two sets of permissions,
-	 * taking all-group access into account
-	 */
-	private function permissionsDiff($permissions1, $permissions2, $userID) {
-		$diff = [];
-		$userGroupLibraries = Zotero_Groups::getUserGroupLibraries($userID);
-		foreach ($permissions2 as $libraryID => $libraryPermissions) {
-			if (!$libraryPermissions['library']) {
-				continue;
-			}
-			if (empty($permissions1[$libraryID]['library'])) {
-				// If second set has a 0 (all-group access), diff is user's groups not
-				// explicitly listed in first set
-				if ($libraryID === 0) {
-					$diff = array_merge(
-						$diff,
-						array_filter(
-							$userGroupLibraries,
-							function ($libraryID) use ($permissions1) {
-								return empty($permissions1[$libraryID]['library']);
-							}
-						)
-					);
-				}
-				else {
-					$libraryType = Zotero_Libraries::getType($libraryID);
-					if ($libraryType == 'user'
-							|| ($libraryType == 'group' && empty($permissions1[0]['library']))) {
-						$diff[] = $libraryID;
-					}
-				}
-			}
-		}
-		return $diff;
-	}
-	
-	
-	public function erase() {
-		if (($this->id || $this->key) && !$this->loaded) {
-			$this->load();
-		}
-		
-		Zotero_DB::beginTransaction();
-		
-		$sql = "DELETE FROM `keys` WHERE keyID=?";
-		$deleted = Zotero_DB::query($sql, $this->id);
-		if (!$deleted) {
-			throw new Exception("Key not deleted");
-		}
-		
-		Zotero_DB::commit();
-		
-		$this->erased = true;
-	}
-	
-	
-	/**
-	 * Converts key to a SimpleXMLElement item
-	 *
-	 * @return	SimpleXMLElement				Key data as SimpleXML element
-	 */
-	public function toXML() {
-		if (($this->id || $this->key) && !$this->loaded) {
-			$this->load();
-		}
-		
-		$xml = '<key/>';
-		$xml = new SimpleXMLElement($xml);
-		
-		$xml['key'] = $this->key;
-		$xml['dateAdded'] = $this->dateAdded;
-		if ($this->lastUsed != '0000-00-00 00:00:00') {
-			$xml['lastUsed'] =  $this->lastUsed;
-		}
-		$xml->name = $this->name;
-		
-		if ($this->permissions) {
-			foreach ($this->permissions as $libraryID=>$p) {
-				$access = $xml->addChild('access');
-				
-				// group="all" is stored as libraryID 0
-				if ($libraryID === 0) {
-					$access['group'] = 'all';
-					if (!empty($p['write'])) {
-						$access['write'] = 1;
-					}
-					continue;
-				}
-				
-				$type = Zotero_Libraries::getType($libraryID);
-				switch ($type) {
-					case 'user':
-						foreach ($p as $permission=>$granted) {
-							$access[$permission] = (int) $granted;
-						}
-						break;
-						
-					case 'group':
-						$access['group'] = Zotero_Groups::getGroupIDFromLibraryID($libraryID);
-						if (!empty($p['write'])) {
-							$access['write'] = 1;
-						}
-						break;
-				}
-			}
-		}
-		
-		$ips = $this->getRecentIPs();
-		if ($ips) {
-			$xml->recentIPs = implode(' ', $ips);
-		}
-		
-		return $xml;
-	}
-	
-	
-	public function toJSON() {
-		if (($this->id || $this->key) && !$this->loaded) {
-			$this->load();
-		}
-		
-		$json = [];
-		if (!empty($_GET['showid'])) {
-			$json['id'] = $this->id;
-		}
-		$json['key'] = $this->key;
-		$json['userID'] = $this->userID;
-		$json['username'] = Zotero_Users::getUsername($this->userID);
-		$json['name'] = $this->name;
-		
-		if ($this->permissions) {
-			$json['access'] = [
-				'user' => [],
-				'groups' => []
-			];
-			
-			foreach ($this->permissions as $libraryID=>$p) {
-				// group="all" is stored as libraryID 0
-				if ($libraryID === 0) {
-					$json['access']['groups']['all']['library'] = true;
-					$json['access']['groups']['all']['write'] = !empty($p['write']);
-				}
-				else {
-					$type = Zotero_Libraries::getType($libraryID);
-					switch ($type) {
-						case 'user':
-							$json['access']['user']['library'] = true;
-							foreach ($p as $permission=>$granted) {
-								if ($permission == 'library') {
-									continue;
-								}
-								$json['access']['user'][$permission] = (bool) $granted;
-							}
-							break;
-							
-						case 'group':
-							$groupID = Zotero_Groups::getGroupIDFromLibraryID($libraryID);
-							$json['access']['groups'][$groupID]['library'] = true;
-							$json['access']['groups'][$groupID]['write'] = !empty($p['write']);
-							break;
-					}
-				}
-			}
-			if (sizeOf($json['access']['user']) === 0) {
-				unset($json['access']['user']);
-			}
-			if (sizeOf($json['access']['groups']) === 0) {
-				unset($json['access']['groups']);
-			}
-		}
-		
-		$json['dateAdded'] = Zotero_Date::sqlToISO8601($this->dateAdded);
-		if ($this->lastUsed != '0000-00-00 00:00:00') {
-			$json['lastUsed'] =  Zotero_Date::sqlToISO8601($this->lastUsed);
-		}
-		
-		$ips = $this->getRecentIPs();
-		if ($ips) {
-			$json['recentIPs'] = $ips;
-		}
-		
-		return $json;
-	}
-	
-	
-	public function loadFromRow($row) {
-		foreach ($row as $field=>$val) {
-			switch ($field) {
-				case 'keyID':
-					$this->id = $val;
-					break;
-					
-				default:
-					$this->$field = $val;
-			}
-		}
-		
-		$this->loaded = true;
-		$this->changed = array();
-		$this->permissions = array();
-	}
-	
-	
-	public function logAccess() {
-		if (!$this->id) {
-			throw new Exception("Key not loaded");
-		}
-		
-		$ip = IPAddress::getIP();
-		
-		// If we already logged access by this key from this IP address
-		// in the last minute, don't do it again
-		$cacheKey = "keyAccessLogged_" . $this->id . "_" . md5($ip);
-		if (Z_Core::$MC->get($cacheKey)) {
-			return;
-		}
-		
-		try {
-			$sql = "UPDATE `keys` SET lastUsed=NOW() WHERE keyID=?";
-			Zotero_DB::query($sql, $this->id, 0, [ 'writeInReadMode' => true ]);
-			if ($ip) {
-				$sql = "REPLACE INTO keyAccessLog (keyID, ipAddress) VALUES (?, INET_ATON(?))";
-				Zotero_DB::query($sql, [$this->id, $ip], 0, [ 'writeInReadMode' => true ]);
-			}
-		}
-		catch (Exception $e) {
-			error_log("WARNING: " . $e);
-		}
-		
-		Z_Core::$MC->set($cacheKey, "1", 600);
-	}
-	
-	
-	private function load() {
-		if ($this->id) {
-			$sql = "SELECT * FROM `keys` WHERE keyID=?";
-			$row = Zotero_DB::rowQuery($sql, $this->id);
-		}
-		else if ($this->key) {
-			$sql = "SELECT * FROM `keys` WHERE `key`=?";
-			$row = Zotero_DB::rowQuery($sql, $this->key);
-		}
-		if (!$row) {
-			return false;
-		}
-		
-		$this->loadFromRow($row);
-		
-		$sql = "SELECT * FROM keyPermissions WHERE keyID=?";
-		$rows = Zotero_DB::query($sql, $this->id);
-		foreach ($rows as $row) {
-			$this->permissions[$row['libraryID']][$row['permission']] = !!$row['granted'];
-			
-			if ($row['permission'] == 'library') {
-				// Key-based access to library provides file access as well
-				$this->permissions[$row['libraryID']]['files'] = !!$row['granted'];
-				
-				if ($row['libraryID'] === 0 || Zotero_Libraries::getType($row['libraryID']) == 'group') {
-					// Key-based access to group libraries implies view and note access
-					$this->permissions[$row['libraryID']]['view'] = !!$row['granted'];
-					$this->permissions[$row['libraryID']]['notes'] = !!$row['granted'];
-				}
-			}
-		}
-	}
-	
-	
-	private function getRecentIPs() {
-		$sql = "SELECT INET_NTOA(ipAddress) FROM keyAccessLog WHERE keyID=?
-				ORDER BY timestamp DESC LIMIT 5";
-		$ips = Zotero_DB::columnQuery($sql, $this->id);
-		if (!$ips) {
-			return array();
-		}
-		return $ips;
-	}
-}
-?>
diff --git a/model/Keys.inc.php b/model/Keys.inc.php
deleted file mode 100644
index 7aea6acb..00000000
--- a/model/Keys.inc.php
+++ /dev/null
@@ -1,102 +0,0 @@
-<?
-/*
-    ***** 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_Keys {
-	public static function getByKey($key) {
-		$sql = "SELECT keyID FROM `keys` WHERE `key`=?";
-		$keyID = Zotero_DB::valueQuery($sql, $key);
-		if (!$keyID) {
-			return false;
-		}
-		$keyObj = new Zotero_Key;
-		$keyObj->id = $keyID;
-		return $keyObj;
-	}
-	
-	
-	public static function getUserKeys($userID) {
-		$keys = array();
-		$keyIDs = Zotero_DB::columnQuery("SELECT keyID FROM `keys` WHERE userID=?", $userID);
-		if ($keyIDs) {
-			foreach ($keyIDs as $keyID) {
-				$keyObj = new Zotero_Key;
-				$keyObj->id = $keyID;
-				$keys[] = $keyObj;
-			}
-		}
-		return $keys;
-	}
-	
-	
-	public static function getUserKeysWithLibrary($userID, $libraryID) {
-		$libraryType = Zotero_Libraries::getType($libraryID);
-		
-		$sql = "SELECT keyID FROM `keys` JOIN keyPermissions USING (keyID) "
-			. "WHERE userID=? AND (libraryID=?";
-		// If group library, include keys with access to all groups
-		if ($libraryType == 'group') {
-			$sql .= " OR libraryID=0";
-		}
-		$sql .= ") AND permission='library' AND granted=1";
-		$keyIDs = Zotero_DB::columnQuery($sql, [$userID, $libraryID]);
-		$keys = [];
-		if ($keyIDs) {
-			foreach ($keyIDs as $keyID) {
-				$keyObj = new Zotero_Key;
-				$keyObj->id = $keyID;
-				$keys[] = $keyObj;
-			}
-		}
-		return $keys;
-	}
-	
-	
-	public static function authenticate($key) {
-		$keyObj = self::getByKey($key);
-		if (!$keyObj) {
-			// TODO: log auth failure
-			return false;
-		}
-		$keyObj->logAccess();
-		return $keyObj;
-	}
-	
-	
-	public static function generate() {
-		$tries = 5;
-		while ($tries > 0) {
-			$str = Zotero_Utilities::randomString(24, 'mixed');
-			$sql = "SELECT COUNT(*) FROM `keys` WHERE `key`=?";
-			if (Zotero_DB::valueQuery($sql, $str)) {
-				$tries--;
-				continue;
-			}
-			return $str;
-		}
-		throw new Exception("Unique key could not be generated");
-	}
-}
-?>
diff --git a/model/Libraries.inc.php b/model/Libraries.inc.php
deleted file mode 100644
index b531fe87..00000000
--- a/model/Libraries.inc.php
+++ /dev/null
@@ -1,525 +0,0 @@
-<?
-/*
-    ***** 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 $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::getUsername($userID);
-			
-			case 'publications':
-				$userID = Zotero_Users::getUserIDFromLibraryID($libraryID);
-				return Zotero_Users::getUsername($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) {
-			trigger_error("Library $libraryID does not exist", E_USER_ERROR);
-		}
-		
-		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 updateVersionAndTimestamp($libraryID) {
-		$timestamp = self::updateTimestamps($libraryID);
-		Zotero_DB::registerTransactionTimestamp($timestamp);
-		self::updateVersion($libraryID);
-	}
-	
-	
-	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 getUserLibraryUpdateTimes($userID) {
-		$libraryIDs = Zotero_Libraries::getUserLibraries($userID);
-		$sql = "SELECT libraryID, UNIX_TIMESTAMP(lastUpdated) AS lastUpdated FROM libraries
-				WHERE libraryID IN ("
-				. implode(',', array_fill(0, sizeOf($libraryIDs), '?'))
-				. ") LOCK IN SHARE MODE";
-		$rows = Zotero_DB::query($sql, $libraryIDs);
-		$updateTimes = array();
-		foreach ($rows as $row) {
-			$updateTimes[$row['libraryID']] = $row['lastUpdated'];
-		}
-		return $updateTimes;
-	}
-	
-	
-	public static function updateTimestamps($libraryIDs) {
-		if (is_scalar($libraryIDs)) {
-			if (!is_numeric($libraryIDs)) {
-				throw new Exception("Invalid library ID");
-			}
-			$libraryIDs = array($libraryIDs);
-		}
-		
-		Zotero_DB::beginTransaction();
-		
-		// TODO: replace with just shardLibraries after sync code removal
-		$sql = "UPDATE libraries SET lastUpdated=NOW() WHERE libraryID IN "
-				. "(" . implode(',', array_fill(0, sizeOf($libraryIDs), '?')) . ")";
-		Zotero_DB::query($sql, $libraryIDs);
-		
-		$sql = "SELECT UNIX_TIMESTAMP(lastUpdated) FROM libraries WHERE libraryID=?";
-		$timestamp = Zotero_DB::valueQuery($sql, $libraryIDs[0]);
-		
-		$sql = "UPDATE shardLibraries SET lastUpdated=FROM_UNIXTIME(?) WHERE libraryID=?";
-		foreach ($libraryIDs as $libraryID) {
-			Zotero_DB::query(
-				$sql,
-				array(
-					$timestamp,
-					$libraryID
-				),
-				Zotero_Shards::getByLibraryID($libraryID)
-			);
-		}
-		
-		Zotero_DB::commit();
-		
-		return $timestamp;
-	}
-	
-	
-	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)
-		);
-		
-		// TEMP: Remove after classic sync, and use shardLibraries only for version info?
-		if (!$version || $version == 1) {
-			$shardID = Zotero_Shards::getByLibraryID($libraryID);
-			$readOnly = Zotero_DB::isReadOnly($shardID);
-			
-			$sql = "SELECT lastUpdated, version FROM libraries WHERE libraryID=?";
-			$row = Zotero_DB::rowQuery($sql, $libraryID);
-			
-			$sql = "UPDATE shardLibraries SET version=?, lastUpdated=? WHERE libraryID=?";
-			Zotero_DB::query(
-				$sql,
-				array($row['version'], $row['lastUpdated'], $libraryID),
-				$shardID,
-				[
-					'writeInReadMode' => true
-				]
-			);
-			$sql = "SELECT IFNULL(IF(MAX(version)=0, 1, MAX(version)), 1) FROM items WHERE libraryID=?";
-			$version = Zotero_DB::valueQuery($sql, $libraryID, $shardID);
-			
-			$sql = "UPDATE shardLibraries SET version=? WHERE libraryID=?";
-			Zotero_DB::query(
-				$sql,
-				[
-					$version,
-					$libraryID
-				],
-				$shardID,
-				[
-					'writeInReadMode' => true
-				]
-			);
-		}
-		
-		// 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 updateVersion($libraryID) {
-		self::getOriginalVersion($libraryID);
-		
-		$shardID = Zotero_Shards::getByLibraryID($libraryID);
-		$sql = "UPDATE shardLibraries SET version=LAST_INSERT_ID(version+1)
-				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;
-		return $version;
-	}
-	
-	
-	public static function isLocked($libraryID) {
-		$sql = "SELECT COUNT(*) FROM syncUploadQueueLocks WHERE libraryID=?";
-		if (Zotero_DB::valueQuery($sql, $libraryID)) {
-			return true;
-		}
-		$sql = "SELECT COUNT(*) FROM syncProcessLocks WHERE libraryID=?";
-		return !!Zotero_DB::valueQuery($sql, $libraryID);
-	}
-	
-	
-	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->isImportedAttachment()
-						&& !$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) {
-		// TODO: cache
-		
-		$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'");
-		}
-		
-		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) {
-			// Delete notes and attachments first (since they may be child items)
-			if ($table == 'items') {
-				$sql = "DELETE FROM $table WHERE libraryID=? AND itemTypeID IN (1,14)";
-				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'
-						&& 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::updateVersion($libraryID);
-		self::updateTimestamps($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);
-		}*/
-	}
-}
-?>
diff --git a/model/Notes.inc.php b/model/Notes.inc.php
deleted file mode 100644
index d1701b1a..00000000
--- a/model/Notes.inc.php
+++ /dev/null
@@ -1,180 +0,0 @@
-<?
-/*
-    ***** 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_Notes {
-	public static $MAX_TITLE_LENGTH = 79;
-	public static $MAX_NOTE_LENGTH = 250000;
-	
-	private static $noteCache = array();
-	private static $hashCache = array();
-	
-	
-	public static function getCachedNote($libraryID, $itemID) {
-		if (!$libraryID) {
-			throw new Exception("Library ID not provided");
-		}
-		if (!$itemID) {
-			throw new Exception("Item ID not provided");
-		}
-		return isset(self::$noteCache[$libraryID][$itemID]) ? self::$noteCache[$libraryID][$itemID] : false;
-	}
-	
-	
-	public static function updateNoteCache($libraryID, $itemID, $note) {
-		if (!$libraryID) {
-			throw new Exception("Library ID not provided");
-		}
-		if (!$itemID) {
-			throw new Exception("Item ID not provided");
-		}
-		if (!isset(self::$noteCache[$libraryID])) {
-			self::$noteCache[$libraryID] = array();
-		}
-		self::$noteCache[$libraryID][$itemID] = $note;
-	}
-	
-	
-	public static function getHash($libraryID, $itemID) {
-		if (!isset(self::$hashCache[$libraryID])) {
-			self::loadHashes($libraryID);
-		}
-		if (!empty(self::$hashCache[$libraryID][$itemID])) {
-			return self::$hashCache[$libraryID][$itemID];
-		}
-		return false;
-	}
-	
-	
-	public static function updateHash($libraryID, $itemID, $value) {
-		if (!isset(self::$hashCache[$libraryID])) {
-			self::$hashCache[$libraryID] = array();
-		}
-		
-		if ($value) {
-			self::$hashCache[$libraryID][$itemID] = $value;
-		}
-		else {
-			unset(self::$hashCache[$libraryID][$itemID]);
-		}
-	}
-	
-	
-	public static function sanitize($text) {
-		if (strlen(trim($text)) == 0) {
-			return $text;
-		}
-		
-		$url = Z_CONFIG::$HTMLCLEAN_SERVER_URL;
-		
-		$start = microtime(true);
-		
-		$ch = curl_init($url);
-		curl_setopt($ch, CURLOPT_POST, 1);
-		curl_setopt($ch, CURLOPT_POSTFIELDS, $text);
-		curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: text/plain"]);
-		curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1);
-		curl_setopt($ch, CURLOPT_TIMEOUT, 2);
-		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;
-		StatsD::timing("api.htmlclean", $time * 1000);
-		
-		$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-		
-		if ($code != 200) {
-			throw new Exception($code . " from htmlclean "
-				. "[URL: '$url'] [INPUT: '" . Zotero_Utilities::ellipsize($text, 100)
-				. "'] [RESPONSE: '$response']");
-		}
-		
-		if (!$response) {
-			error_log($code);
-			error_log($time);
-			error_log($url);
-		}
-		
-		if (!$response) {
-			throw new Exception("Error cleaning note");
-		}
-		
-		return $response;
-	}
-	
-	
-	/**
-	 * Return first line (or first MAX_LENGTH characters) of note content
-	 *
-	 * input should be sanitized already
-	 */
-	public static function noteToTitle($text, $ignoreNewline=false) {
-		if (!$text) {
-			return '';
-		}
-		$max = self::$MAX_TITLE_LENGTH;
-		
-		// Get a reasonable beginning to work with
-		$text = mb_substr($text, 0, $max * 5);
-		
-		// Clean and unencode
-		$text = preg_replace("/<\/p>[\s]*<p>/", "</p>\n<p>", $text);
-		$text = strip_tags($text);
-		$text = html_entity_decode($text, ENT_COMPAT, 'UTF-8');
-		
-		$t = mb_strcut($text, 0, $max);
-		if ($ignoreNewline) {
-			$t = preg_replace('/\s+/', ' ', $t);
-		}
-		else {
-			$ln = mb_strpos($t, "\n");
-			if ($ln !== false && $ln < $max) {
-				$t = mb_strcut($t, 0, $ln);
-			}
-		}
-		return $t;
-	}
-	
-	
-	public static function loadHashes($libraryID) {
-		$sql = "SELECT itemID, hash FROM itemNotes JOIN items USING (itemID) WHERE libraryID=?";
-		$hashes = Zotero_DB::query($sql, $libraryID, Zotero_Shards::getByLibraryID($libraryID));
-		if (!$hashes) {
-			return;
-		}
-		
-		if (!isset(self::$hashCache[$libraryID])) {
-			self::$hashCache[$libraryID] = array();
-		}
-		
-		foreach ($hashes as $hash) {
-			if ($hash['hash']) {
-				self::$hashCache[$libraryID][$hash['itemID']] = $hash['hash'];
-			}
-		}
-	}
-}
-?>
diff --git a/model/Notifier.inc.php b/model/Notifier.inc.php
deleted file mode 100644
index e354cc45..00000000
--- a/model/Notifier.inc.php
+++ /dev/null
@@ -1,292 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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_Notifier {
-	private static $locked = false;
-	private static $queue = array();
-	private static $inTransaction = false;
-	private static $observers = array();
-	
-	private static $types = array(
-		'apikey-library', 'collection', 'creator', 'search', 'share', 'share-items', 'item',
-		'collection-item', 'item-tag', 'tag', 'group', 'trash', 'relation',
-		'library', 'publications'
-	);
-	
-	/**
-	 * @param $observer Class Class with method notify($event, $type, $ids, $extraData)
-	 * @param $types [array] Types to receive notications for
-	 * @param $label [string] Name of the observer
-	 */
-	public static function registerObserver($observer, $types=[], $name='') {
-		if (is_scalar($types)) {
-			$types = array($types);
-		}
-		foreach ($types as $type) {
-			if (!in_array($type, self::$types)) {
-				throw new Exception("Invalid type '$type'");
-			}
-		}
-		
-		if (empty($name) || isset(self::$observers[$name])) {
-			$len = 2;
-			$tries = 10;
-			do {
-				// Increase the hash length if we can't find a unique key
-				if (!$tries) {
-					$len++;
-					$tries = 5;
-				}
-				
-				$key = strlen($name) ? "$name-" : '';
-				$key .= Zotero_Utilities::randomString($len, 'mixed');
-				$tries--;
-			}
-			while (isset(self::$observers[$key]));
-		}
-		else {
-			$key = $name;
-		}
-		
-		Z_Core::debug('Registering observer for '
-			. ($types ? '[' . implode(",", $types) . ']' : 'all types')
-			. ' in notifier ' . $key . "'", 4);
-		self::$observers[$key] = array(
-			"observer" => $observer,
-			"types" => $types
-		);
-		return $key;
-	}
-	
-	
-	public static function unregisterObserver($key) {
-		Z_Core::debug("Unregistering observer in notifier '$key'", 4);
-		unset(self::$observers[$key]);
-	}
-	
-	
-	/**
-	* Trigger a notification to the appropriate observers
-	*
-	* Possible values:
-	*
-	* 	event: 'add', 'modify', 'delete', 'move' ('c', for changing parent),
-	*		'remove' (ci, it), 'refresh', 'redraw', 'trash'
-	* 	type - 'collection', 'search', 'item', 'collection-item', 'item-tag', 'tag', 'group', 'relation'
-	* 	ids - single id or array of ids
-	*
-	* Notes:
-	*
-	* - If event queuing is on, events will not fire until commit() is called
-	* unless $force is true.
-	*
-	* - New events and types should be added to the order arrays in commit()
-	**/
-	public static function trigger($event, $type, $ids, $extraData=null, $force=false) {
-		if (!in_array($type, self::$types)) {
-			throw new Exception("Invalid type '$type'");
-		}
-		
-		if (is_scalar($ids)) {
-			$ids = array($ids);
-		}
-		
-		$queue = self::$inTransaction && !$force;
-		
-		Z_Core::debug("Notifier trigger('$event', '$type', [" . implode(",", $ids) . '])'
-			. ($queue ? " queued" : " called " . "[observers: " . sizeOf(self::$observers) . "]"));
-		
-		// Merge with existing queue
-		if ($queue) {
-			if (!isset(self::$queue[$type])) {
-				self::$queue[$type] = array();
-			}
-			if (!isset(self::$queue[$type][$event])) {
-				self::$queue[$type][$event] = array(
-					"ids" => array(),
-					"data" => array()
-				);
-			}
-			
-			// Merge ids
-			self::$queue[$type][$event]['ids'] = array_merge(
-				self::$queue[$type][$event]['ids'], $ids
-			);
-			
-			// Merge extraData keys
-			if ($extraData) {
-				foreach ($extraData as $dataID => $data) {
-					self::$queue[$type][$event]['data'][$dataID] = $data;
-				}
-			}
-			
-			return true;
-		}
-		
-		foreach (self::$observers as $hash => $observer) {
-			// Find observers that handle notifications for this type (or all types)
-			if (!$observer['types'] || in_array($type, $observer['types'])) {
-				Z_Core::debug("Calling notify('$event', '$type', ...) on observer with hash '$hash'", 4);
-				
-				// Catch exceptions so all observers get notified even if
-				// one throws an error
-				try {
-					call_user_func_array(
-						array($observer['observer'], "notify"),
-						array($event, $type, $ids, $extraData)
-					);
-				}
-				catch (Exception $e) {
-					error_log("WARNING: $e");
-				}
-			}
-		}
-		
-		return true;
-	}
-	
-	
-	/*
-	 * Begin queueing event notifications (i.e., don't notify the observers)
-	 *
-	 * $lock will prevent subsequent commits from running the queue until
-	 * commit() is called with $unlock set to true
-	 */
-	public static function begin($lock=false) {
-		if ($lock && !self::$locked) {
-			self::$locked = true;
-			$unlock = true;
-		}
-		else {
-			$unlock = false;
-		}
-		
-		if (self::$inTransaction) {
-			//Zotero.debug("Notifier queue already open", 4);
-		}
-		else {
-			Z_Core::debug("Beginning Notifier event queue");
-			self::$inTransaction = true;
-		}
-		
-		return $unlock;
-	}
-	
-	
-	/*
-	 * Send notifications for ids in the event queue
-	 *
-	 * If the queue is locked, notifications will only run if $unlock is true
-	 */
-	public static function commit($unlock=null) {
-		if (!self::$queue) {
-			self::reset();
-			return;
-		}
-		
-		// If there's a lock on the event queue and $unlock isn't given, don't commit
-		if (($unlock === null && self::$locked) || $unlock === false) {
-			//Zotero.debug("Keeping Notifier event queue open", 4);
-			return;
-		}
-		
-		$runQueue = array();
-		
-		$order = array(
-			'library',
-			'collection',
-			'search',
-			'item',
-			'collection-item',
-			'item-tag',
-			'tag'
-		);
-		uasort(self::$queue, function ($a, $b) use ($order) {
-			return array_search($b, $order) - array_search($a, $order);
-		});
-		
-		$order = array('add', 'modify', 'remove', 'move', 'delete', 'trash');
-		$totals = '';
-		foreach (array_keys(self::$queue) as $type) {
-			if (!isset($runQueue[$type])) {
-				$runQueue[$type] = array();
-			}
-			
-			asort(self::$queue[$type]);
-			
-			foreach (self::$queue[$type] as $event => $obj) {
-				$runObj = array(
-					'ids' => array(),
-					'data' => array()
-				);
-				
-				// Remove redundant ids
-				for ($i = 0, $len = sizeOf($obj['ids']); $i < $len; $i++) {
-					$id = $obj['ids'][$i];
-					$data = isset($obj['data'][$id]) ? $obj['data'][$id] : null;
-					
-					if (!in_array($id, $runObj['ids'])) {
-						$runObj['ids'][] = $id;
-						$runObj['data'][$id] = $data;
-					}
-				}
-				
-				if ($runObj['ids'] || $event == 'refresh') {
-					$totals .= " [$event-$type: " . sizeOf($runObj['ids']) . "]";
-				}
-				
-				$runQueue[$type][$event] = $runObj;
-			}
-		}
-		
-		self::reset();
-		
-		if ($totals) {
-			Z_Core::debug("Committing Notifier event queue" . $totals);
-			
-			foreach (array_keys($runQueue) as $type) {
-				foreach ($runQueue[$type] as $event => $obj) {
-					if (sizeOf($obj['ids']) || $event == 'refresh') {
-						self::trigger(
-							$event, $type, $obj['ids'], $obj['data'], true
-						);
-					}
-				}
-			}
-		}
-	}
-	
-	
-	/*
-	 * Reset the event queue
-	 */
-	public static function reset() {
-		Z_Core::debug("Resetting Notifier event queue");
-		self::$locked = false;
-		self::$queue = array();
-		self::$inTransaction = false;
-	}
-}
diff --git a/model/NotifierObserver.inc.php b/model/NotifierObserver.inc.php
deleted file mode 100644
index f8189a7a..00000000
--- a/model/NotifierObserver.inc.php
+++ /dev/null
@@ -1,162 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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_NotifierObserver {
-	private static $messageReceivers = [];
-	private static $continued = false;
-	
-	public static function init($messageReceiver=null) {
-		Zotero_Notifier::registerObserver(
-			__CLASS__,
-			["library", "publications", "apikey-library"],
-			"NotifierObserver"
-		);
-		
-		// Send notifications to Redis by default
-		self::$messageReceivers[] = function ($topic, $message) {
-			$redis = Z_Redis::get('notifications');
-			if (!$redis) {
-				Z_Core::logError('Error: Failed to get Redis client for notifications');
-				return;
-			};
-			$redis->publish($topic, $message);
-		};
-	}
-	
-	public static function setContinued() {
-		self::$continued = true;
-	}
-	
-	public static function addMessageReceiver($messageReceiver) {
-		self::$messageReceivers[] = $messageReceiver;
-	}
-	
-	
-	public static function notify($event, $type, $ids, $extraData) {
-		if ($type == "library" || $type == "publications") {
-			switch ($event) {
-			case "modify":
-				$event = "topicUpdated";
-				break;
-			
-			case "delete":
-				$event = "topicDeleted";
-				break;
-			
-			default:
-				return;
-			}
-			
-			foreach ($ids as $id) {
-				$libraryID = $id;
-				// For most libraries, get topic from URI
-				if ($event != 'topicDeleted') {
-					// Convert 'http://zotero.org/users/...' to '/users/...'
-					$topic = str_replace(
-						Zotero_URI::getBaseURI(), "/", Zotero_URI::getLibraryURI($libraryID)
-					);
-					if ($type == 'publications') {
-						$topic .= '/publications';
-					}
-				}
-				// For deleted libraries (groups), the URI-based method fails,
-				// so just build from parts
-				else {
-					if (!isset($extraData) || !isset($extraData[$libraryID])) {
-						error_log("Extra data isn't set for $event");
-						continue;
-					}
-					// /groups/1234
-					$topic = '/' . $extraData[$libraryID]['type'] . "s/"
-						. $extraData[$libraryID]['libraryTypeID'];
-				}
-				$message = [
-					"event" => $event,
-					"topic" => $topic
-				];
-				
-				if (self::$continued) {
-					$message['continued'] = true;
-				}
-				
-				if (!empty($extraData[$id])) {
-					foreach ($extraData[$id] as $key => $val) {
-						$message[$key] = $val;
-					}
-				}
-				self::send($topic, $message);
-			}
-		}
-		else if ($type == "apikey-library") {
-			switch ($event) {
-			case "add":
-				$event = "topicAdded";
-				break;
-			
-			case "remove":
-				$event = "topicRemoved";
-				break;
-			
-			default:
-				return;
-			}
-			
-			foreach ($ids as $id) {
-				list($apiKeyID, $libraryID) = explode("-", $id);
-				// Get topic from URI
-				$topic = str_replace(
-					Zotero_URI::getBaseURI(), "/", Zotero_URI::getLibraryURI($libraryID)
-				);
-				$message = [
-					"event" => $event,
-					"apiKeyID" => $apiKeyID,
-					"topic" => $topic
-				];
-				
-				if (self::$continued) {
-					$message['continued'] = true;
-				}
-				
-				if (!empty($extraData[$id])) {
-					foreach ($extraData[$id] as $key => $val) {
-						$message[$key] = $val;
-					}
-				}
-				
-				self::send("api-key:$apiKeyID", $message);
-			}
-		}
-	}
-	
-	private static function send($channel, $message) {
-		foreach (self::$messageReceivers as $receiver) {
-			$receiver(
-				$channel,
-				json_encode($message, JSON_UNESCAPED_SLASHES)
-			);
-		}
-	}
-}
diff --git a/model/Permissions.inc.php b/model/Permissions.inc.php
deleted file mode 100644
index 83ffe8b2..00000000
--- a/model/Permissions.inc.php
+++ /dev/null
@@ -1,298 +0,0 @@
-<?
-/*
-    ***** 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_Permissions {
-	private $super = false;
-	private $anonymous = false;
-	private $publications = false;
-	private $userID = null;
-	private $permissions = array();
-	private $userPrivacy = array();
-	private $groupPrivacy = array();
-	
-	
-	public function __construct($userID=null) {
-		$this->userID = $userID;
-	}
-	
-	
-	public function canAccess($libraryID, $permission='library') {
-		if ($this->super) {
-			return true;
-		}
-		
-		if (!$libraryID) {
-			throw new Exception('libraryID not provided');
-		}
-		
-		// TEMP: necessary?
-		$libraryID = (int) $libraryID;
-		
-		// If requested permission is explicitly set
-		//
-		// This assumes that permissions can't be incorrectly set
-		// (e.g., are properly removed when a user loses group access)
-		if (!empty($this->permissions[$libraryID][$permission])) {
-			return true;
-		}
-		
-		$libraryType = Zotero_Libraries::getType($libraryID);
-		switch ($libraryType) {
-			case 'user':
-				$userID = Zotero_Users::getUserIDFromLibraryID($libraryID);
-				$privacy = $this->getUserPrivacy($userID);
-				break;
-			
-			// TEMP
-			case 'publications':
-				return true;
-			
-			case 'group':
-				$groupID = Zotero_Groups::getGroupIDFromLibraryID($libraryID);
-				
-				// If key has access to all groups, grant access if user
-				// has read access to group
-				if (!empty($this->permissions[0]['library'])) {
-					$group = Zotero_Groups::get($groupID);
-					
-					// Only members have file access
-					if ($permission == 'files') {
-						return !!$group->getUserRole($this->userID);
-					}
-					
-					if ($group->userCanRead($this->userID)) {
-						return true;
-					}
-				}
-				
-				$privacy = $this->getGroupPrivacy($groupID);
-				break;
-			
-			default:
-				throw new Exception("Unsupported library type '$libraryType'");
-		}
-		
-		switch ($permission) {
-		case 'view':
-			return $privacy['view'];
-		
-		case 'library':
-			return $privacy['library'];
-		
-		case 'notes':
-			return $privacy['notes'];
-		
-		default:
-			return false;
-		}
-	}
-	
-	
-	public function canAccessObject(Zotero_DataObject $obj) {
-		if ($obj instanceof Zotero_Item && $this->publications && $obj->inPublications) {
-			return true;
-		}
-		
-		$scope = 'library';
-		if ($obj instanceof Zotero_Item && $obj->isNote()) {
-			$scope = 'notes';
-		}
-		return $this->canAccess($obj->libraryID, $scope);
-	}
-	
-	
-	/**
-	 * This should be called after canAccess()
-	 */
-	public function canWrite($libraryID) {
-		if ($this->super) {
-			return true;
-		}
-		
-		if ($libraryID === 0) {
-			return false;
-		}
-		
-		if (!$libraryID) {
-			throw new Exception('libraryID not provided');
-		}
-		
-		if (!empty($this->permissions[$libraryID]['write'])) {
-			return true;
-		}
-		
-		$libraryType = Zotero_Libraries::getType($libraryID);
-		switch ($libraryType) {
-			case 'user':
-				return false;
-			
-			// Write permissions match key's write access to user library
-			case 'publications':
-				$userLibraryID = Zotero_Users::getLibraryIDFromUserID($this->userID);
-				return $this->canWrite($userLibraryID);
-			
-			case 'group':
-				$groupID = Zotero_Groups::getGroupIDFromLibraryID($libraryID);
-				
-				// If key has write access to all groups, grant access if user
-				// has write access to group
-				if (!empty($this->permissions[0]['write'])) {
-					$group = Zotero_Groups::get($groupID);
-					return $group->userCanEdit($this->userID);
-				}
-				
-				return false;
-			
-			default:
-				throw new Exception("Unsupported library type '$libraryType'");
-		}
-	}
-	
-	
-	public function setPermission($libraryID, $permission, $enabled) {
-		if ($this->super) {
-			throw new Exception("Super-user permissions already set");
-		}
-		
-		switch ($permission) {
-			case 'view':
-			case 'library':
-			case 'notes':
-			case 'files':
-			case 'write':
-				break;
-			
-			default:
-				throw new Exception("Invalid permission '$permission'");
-		}
-		
-		$this->permissions[$libraryID][$permission] = $enabled;
-	}
-	
-	
-	public function setAnonymous() {
-		$this->anonymous = true;
-	}
-	
-	public function setPublications() {
-		$this->publications = true;
-	}
-	
-	public function setUser($userID) {
-		$this->userID = $userID;
-	}
-	
-	public function setSuper() {
-		$this->super = true;
-	}
-	
-	public function isSuper() {
-		return $this->super;
-	}
-	
-	
-	private function getUserPrivacy($userID) {
-		if (isset($this->userPrivacy[$userID])) {
-			return $this->userPrivacy[$userID];
-		}
-		
-		if (Z_ENV_DEV_SITE) {
-			// Hard-coded test values
-			$privacy = array();
-			
-			switch ($userID) {
-				case 1:
-					$privacy['library'] = true;
-					$privacy['notes'] = true;
-					break;
-					
-				case 2:
-					$privacy['library'] = false;
-					$privacy['notes'] = false;
-					break;
-				
-				default:
-					throw new Exception("External requests disabled on dev site");
-			}
-			
-			$this->userPrivacy[$userID] = $privacy;
-			return $privacy;
-		}
-		
-		$sql = "SELECT metaKey, metaValue FROM users_meta WHERE userID=? AND metaKey LIKE 'privacy_publish%'";
-		try {
-			$rows = Zotero_WWW_DB_2::query($sql, $userID);
-		}
-		catch (Exception $e) {
-			Z_Core::logError("WARNING: $e -- retrying on primary");
-			$rows = Zotero_WWW_DB_1::query($sql, $userID);
-		}
-		
-		$privacy = array(
-			'library' => false,
-			'notes' => false
-		);
-		foreach ($rows as $row) {
-			$privacy[strtolower(substr($row['metaKey'], 15))] = (bool) (int) $row['metaValue'];
-		}
-		$this->userPrivacy[$userID] = $privacy;
-		
-		return $privacy;
-	}
-	
-	
-	private function getGroupPrivacy($groupID) {
-		if (isset($this->groupPrivacy[$groupID])) {
-			return $this->groupPrivacy[$groupID];
-		}
-		
-		$group = Zotero_Groups::get($groupID);
-		if (!$group) {
-			throw new Exception("Group $groupID doesn't exist");
-		}
-		$privacy = array();
-		if ($group->isPublic()) {
-			$privacy['view'] = true;
-			$privacy['library'] = $group->libraryReading == 'all';
-			$privacy['notes'] = $group->libraryReading == 'all';
-		}
-		else {
-			$privacy['view'] = false;
-			$privacy['library'] = false;
-			$privacy['notes'] = false;
-		}
-		
-		$this->groupPrivacy[$groupID] = $privacy;
-		
-		return $privacy;
-	}
-	
-	
-	public function hasPermission($object, $userID=false) {
-		return false;
-	}
-}
-?>
diff --git a/model/Processor.inc.php b/model/Processor.inc.php
deleted file mode 100644
index e6206b35..00000000
--- a/model/Processor.inc.php
+++ /dev/null
@@ -1,153 +0,0 @@
-<?
-/*
-    ***** 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 *****
-*/
-
-abstract class Zotero_Processor {
-	protected $id;
-	
-	public function run($id=null) {
-		$this->id = $id;
-		$this->addr = gethostbyname(gethostname());
-		
-		if (!Z_CONFIG::$PROCESSORS_ENABLED) {
-			$sleep = 20;
-			$this->log("Processors disabled — exiting in $sleep seconds");
-			sleep($sleep);
-			try {
-				$this->notifyProcessor("LOCK" . " " . $id);
-			}
-			catch (Exception $e) {
-				$this->log($e);
-			}
-			return;
-		}
-		
-		$this->log("Starting sync processor");
-		
-		$startTime = microtime(true);
-		try {
-			$processed = $this->processFromQueue();
-			if (Zotero_DB::transactionInProgress()) {
-				error_log("WARNING: Transaction still in progress after processing!");
-			}
-		}
-		catch (Exception $e) {
-			$this->log($e);
-			throw ($e);
-		}
-		
-		$duration = microtime(true) - $startTime;
-		
-		$error = false;
-		
-		// Success
-		if ($processed == 1) {
-			$this->log("Process completed in " . round($duration, 2) . " seconds");
-			$signal = "DONE";
-		}
-		else if ($processed == 0) {
-			$this->log("Exiting with no processes found");
-			$signal = "NONE";
-		}
-		else if ($processed == -1) {
-			$this->log("Exiting on lock error");
-			$signal = "LOCK";
-		}
-		else {
-			$this->log("Exiting on error");
-			$signal = "ERROR";
-		}
-		
-		if ($id) {
-			try {
-				$this->notifyProcessor($signal . " " . $id);
-			}
-			catch (Exception $e) {
-				$this->log($e);
-			}
-		}
-	}
-	
-	
-	protected function notifyProcessor($signal) {
-		Zotero_Processors::notifyProcessor($this->mode, $signal, $this->addr, $this->port);
-	}
-	
-	
-	private function log($msg) {
-		$targetVariable = "PROCESSOR_LOG_TARGET_" . strtoupper($this->mode);
-		Z_Log::log(Z_CONFIG::$$targetVariable, "[" . $this->id . "] $msg");
-	}
-	
-	
-	//
-	// Abstract methods
-	//
-	abstract protected function processFromQueue();
-}
-
-class Zotero_Download_Processor extends Zotero_Processor {
-	protected $mode = 'download';
-	
-	public function __construct() {
-		$this->port = Z_CONFIG::$PROCESSOR_PORT_DOWNLOAD;
-	}
-	
-	protected function processFromQueue() {
-		return Zotero_Sync::processDownloadFromQueue($this->id);
-	}
-}
-
-class Zotero_Upload_Processor extends Zotero_Processor {
-	protected $mode = 'upload';
-	
-	public function __construct() {
-		$this->port = Z_CONFIG::$PROCESSOR_PORT_UPLOAD;
-	}
-	
-	protected function processFromQueue() {
-		return Zotero_Sync::processUploadFromQueue($this->id);
-	}
-}
-
-class Zotero_Error_Processor extends Zotero_Processor {
-	protected $mode = 'error';
-	
-	public function __construct() {
-		$this->port = Z_CONFIG::$PROCESSOR_PORT_ERROR;
-	}
-	
-	protected function processFromQueue() {
-		return Zotero_Sync::checkUploadForErrors($this->id);
-	}
-	
-	protected function notifyProcessor($signal) {
-		// Tell the upload processor a process is available
-		Zotero_Processors::notifyProcessors('upload', $signal);
-		
-		Zotero_Processors::notifyProcessor($this->mode, $signal, $this->addr, $this->port);
-	}
-}
-?>
diff --git a/model/ProcessorDaemon.inc.php b/model/ProcessorDaemon.inc.php
deleted file mode 100644
index f00cbbdb..00000000
--- a/model/ProcessorDaemon.inc.php
+++ /dev/null
@@ -1,402 +0,0 @@
-<?
-/*
-    ***** 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 *****
-*/
-
-abstract class Zotero_Processor_Daemon {
-	// Default is no concurrency
-	private $maxProcessors = 1;
-	private $minPurgeInterval = 20; // minimum time between purging orphaned processors
-	private $minCheckInterval = 15; // minimum time between checking queued processes on NEXT
-	private $lockWait = 2; // Delay when a processor returns a LOCK signal
-	
-	private $hostname;
-	private $addr;
-	
-	private $processors = array();
-	private $queuedProcesses = 0;
-	
-	// Set by implementors
-	protected $mode;
-	protected $port;
-	
-	public function __construct($config=array()) {
-		error_reporting(E_ALL | E_STRICT);
-		set_time_limit(0);
-		
-		$this->hostname = gethostname();
-		$this->addr = gethostbyname($this->hostname);
-		
-		// Set configuration parameters
-		foreach ($config as $key => $val) {
-			switch ($key) {
-				case 'maxProcessors':
-				case 'minPurgeInterval':
-				case 'minCheckInterval':
-				case 'lockWait':
-					$this->$key = $val;
-					break;
-				
-				default:
-					throw new Exception("Invalid configuration key '$key'");
-			}
-		}
-	}
-	
-	
-	public function run() {
-		// Catch TERM and unregister from the database
-		//pcntl_signal(SIGTERM, array($this, 'handleSignal'));
-		//pcntl_signal(SIGINT, array($this, 'handleSignal'));
-		
-		$this->log("Starting " . $this->mode . " processor daemon");
-		$this->register();
-		
-		// Bind
-		$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
-		$success = socket_bind($socket, $this->addr, $this->port);
-		if (!$success) {
-			$code = socket_last_error($socket);
-			$this->unregister();
-			die(socket_strerror($code));
-		}
-		
-		$buffer = 'GO';
-		$mode = null;
-		
-		$first = true;
-		$lastPurge = 0;
-		
-		do {
-			if ($first) {
-				$first = false;
-			}
-			else {
-				//$this->log("Waiting for command");
-				$from = '';
-				$port = 0;
-				socket_recvfrom($socket, $buffer, 32, MSG_WAITALL, $from, $port);
-			}
-			
-			//pcntl_signal_dispatch();
-			
-			// Processor return value
-			if (preg_match('/^(DONE|NONE|LOCK|ERROR) ([0-9]+)/', $buffer, $return)) {
-				$signal = $return[1];
-				$id = $return[2];
-				
-				$this->removeProcessor($id);
-				
-				if ($signal == "DONE" || $signal == "ERROR") {
-				
-				}
-				else if ($signal == "NONE") {
-					continue;
-				}
-				else if ($signal == "LOCK") {
-					$this->log("LOCK received — waiting " . $this->lockWait . " second" . $this->pluralize($this->lockWait));
-					sleep($this->lockWait);
-				}
-				
-				$buffer = "GO";
-			}
-			
-			if ($buffer == "NEXT" || $buffer == "GO") {
-				if ($lastPurge == 0) {
-					$lastPurge = microtime(true);
-				}
-				// Only purge processors if enough time has passed since last check
-				else if ((microtime(true) - $lastPurge) >= $this->minPurgeInterval) {
-					$purged = $this->purgeProcessors();
-					$this->log("Purged $purged lost processor" . $this->pluralize($purged));
-					$purged = $this->purgeOldProcesses();
-					$this->log("Purged $purged old process" . $this->pluralize($purged, "es"));
-					$lastPurge = microtime(true);
-				}
-				
-				$numProcessors = $this->countProcessors();
-				
-				if ($numProcessors >= $this->maxProcessors) {
-					//$this->log("Already at max " . $this->maxProcessors . " processors");
-					continue;
-				}
-				
-				try {
-					$queuedProcesses = $this->countQueuedProcesses();
-					
-					$this->log($numProcessors . " processor" . $this->pluralize($numProcessors) . ", "
-						. $queuedProcesses . " queued process" . $this->pluralize($queuedProcesses, "es"));
-					
-					// Nothing queued, so go back and wait
-					if (!$queuedProcesses) {
-						continue;
-					}
-					
-					// Wanna be startin' somethin'
-					$maxToStart = $this->maxProcessors - $numProcessors;
-					if ($queuedProcesses > $maxToStart) {
-						$toStart = $maxToStart;
-					}
-					else {
-						$toStart = 1;
-					}
-					
-					if ($toStart <= 0) {
-						$this->log("No processors to start");
-						continue;
-					}
-					
-					$this->log("Starting $toStart new processor" . $this->pluralize($toStart));
-					
-					// Start new processors
-					for ($i=0; $i<$toStart; $i++) {
-						$id = Zotero_ID::getBigInt();
-						$pid = shell_exec(
-							Z_CONFIG::$CLI_PHP_PATH . " " . Z_ENV_BASE_PATH . "processor/"
-							. $this->mode . "/processor.php $id > /dev/null & echo $!"
-						);
-						$this->processors[$id] = $pid;
-					}
-				}
-				catch (Exception $e) {
-					// If lost connection to MySQL, exit so we can be restarted
-					if (strpos($e->getMessage(), "MySQL server has gone away") === 0) {
-						$this->log($e);
-						$this->log("Lost connection to DB — exiting");
-						exit;
-					}
-					
-					$this->log($e);
-				}
-			}
-		}
-		while ($buffer != 'QUIT');
-		
-		$this->log("QUIT received — exiting");
-		$this->unregister();
-	}
-	
-	
-	public function handleSignal($sig) {
-		$signal = $sig;
-		switch ($sig) {
-			case 2:
-				$signal = 'INT';
-				break;
-			
-			case 15:
-				$signal = 'TERM';
-				break;
-		}
-		$this->log("Got $signal signal — exiting");
-		$this->unregister();
-		exit;
-	}
-	
-	
-	private function register() {
-		Zotero_Processors::register($this->mode, $this->addr, $this->port);
-	}
-	
-	
-	private function unregister() {
-		Zotero_Processors::unregister($this->mode, $this->addr, $this->port);
-	}
-	
-	
-	private function countProcessors() {
-		return sizeOf($this->processors);
-	}
-	
-	
-	private function removeProcessor($id) {
-		if (!isset($this->processors[$id])) {
-			//$this->log("Process $id not found for removal");
-		}
-		else {
-			unset($this->processors[$id]);
-		}
-	}
-	
-	
-	private function purgeProcessors() {
-		$ids = array_keys($this->processors);
-		$purged = 0;
-		foreach ($ids as $id) {
-			if (!$this->isRunning($this->processors[$id])) {
-				$this->log("Purging lost processor $id");
-				unset($this->processors[$id]);
-				$this->removeProcess($id);
-				$purged++;
-			}
-		}
-		return $purged;
-	}
-	
-	
-	/**
-	 * Remove process id from any processes in DB that we have no record of
-	 * (e.g., started in a previous daemon session) and that are no longer running
-	 */
-	private function purgeOldProcesses() {
-		$processes = $this->getOldProcesses($this->hostname);
-		if (!$processes) {
-			return 0;
-		}
-		
-		$removed = 0;
-		foreach ($processes as $id) {
-			if (isset($this->processors[$id])) {
-				continue;
-			}
-			// Check if process is running
-			if (!$this->isRunningByID($id)) {
-				$this->log("Purging lost process $id");
-				$this->removeProcess($id);
-				$removed++;
-			}
-		}
-		return $removed;
-	}
-	
-	
-	private function isRunning($pid) {
-		exec("ps $pid", $state);
-		return sizeOf($state) >= 2;
-	}
-	
-	
-	private function isRunningByID($id) {
-		exec("ps | grep $id | grep -v grep", $state);
-		return (bool) sizeOf($state);
-	}
-	
-	
-	private function pluralize($num, $plural="s") {
-		return $num == 1 ? "" : $plural;
-	}
-	
-	
-	//
-	// Abstract methods
-	//
-	abstract public function log($msg);
-	abstract protected function countQueuedProcesses();
-	
-	/**
-	 * Get from the DB any processes that have been running
-	 * longer than a given period of time
-	 */
-	abstract protected function getOldProcesses($host=null, $seconds=null);
-	
-	/**
-	 * Remove process id from DB
-	 */
-	abstract protected function removeProcess($id);
-}
-
-
-class Zotero_Download_Processor_Daemon extends Zotero_Processor_Daemon {
-	protected $mode = 'download';
-	
-	public function __construct($config=array()) {
-		$this->port = Z_CONFIG::$PROCESSOR_PORT_DOWNLOAD;
-		if (!$config || !isset($config['maxProcessors'])) {
-			$config['maxProcessors'] = 3;
-		}
-		parent::__construct($config);
-	}
-	
-	public function log($msg) {
-		Z_Log::log(Z_CONFIG::$PROCESSOR_LOG_TARGET_DOWNLOAD, $msg);
-	}
-	
-	protected function countQueuedProcesses() {
-		return Zotero_Sync::countQueuedDownloadProcesses();
-	}
-	
-	protected function getOldProcesses($host=null, $seconds=null) {
-		return Zotero_Sync::getOldDownloadProcesses($host, $seconds);
-	}
-	
-	protected function removeProcess($id) {
-		Zotero_Sync::removeDownloadProcess($id);
-	}
-}
-
-
-class Zotero_Upload_Processor_Daemon extends Zotero_Processor_Daemon {
-	protected $mode = 'upload';
-	
-	public function __construct($config=array()) {
-		$this->port = Z_CONFIG::$PROCESSOR_PORT_UPLOAD;
-		if (!$config || !isset($config['maxProcessors'])) {
-			$config['maxProcessors'] = 3;
-		}
-		parent::__construct($config);
-	}
-	
-	public function log($msg) {
-		Z_Log::log(Z_CONFIG::$PROCESSOR_LOG_TARGET_UPLOAD, $msg);
-	}
-	
-	protected function countQueuedProcesses() {
-		return Zotero_Sync::countQueuedUploadProcesses();
-	}
-	
-	protected function getOldProcesses($host=null, $seconds=null) {
-		return Zotero_Sync::getOldUploadProcesses($host, $seconds);
-	}
-	
-	protected function removeProcess($id) {
-		Zotero_Sync::removeUploadProcess($id);
-	}
-}
-
-
-class Zotero_Error_Processor_Daemon extends Zotero_Processor_Daemon {
-	protected $mode = 'error';
-	
-	public function __construct($config=array()) {
-		$this->port = Z_CONFIG::$PROCESSOR_PORT_ERROR;
-		parent::__construct($config);
-	}
-	
-	public function log($msg) {
-		Z_Log::log(Z_CONFIG::$PROCESSOR_LOG_TARGET_ERROR, $msg);
-	}
-	
-	protected function countQueuedProcesses() {
-		return Zotero_Sync::countQueuedUploadProcesses(true);
-	}
-	
-	protected function getOldProcesses($host=null, $seconds=null) {
-		return Zotero_Sync::getOldErrorProcesses($host, $seconds);
-	}
-	
-	protected function removeProcess($id) {
-		Zotero_Sync::purgeErrorProcess($id);
-	}
-}
-?>
diff --git a/model/Processors.inc.php b/model/Processors.inc.php
deleted file mode 100644
index 05f900f6..00000000
--- a/model/Processors.inc.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?
-class Zotero_Processors {
-	public static function notifyProcessors($mode, $signal="NEXT") {
-		$sql = "SELECT INET_NTOA(addr) AS addr, port FROM processorDaemons WHERE mode=?";
-		$daemons = Zotero_DB::query($sql, array($mode));
-		
-		if (!$daemons) {
-			Z_Core::logError("No $mode processor daemons found");
-			return;
-		}
-		
-		foreach ($daemons as $daemon) {
-			self::notifyProcessor($mode, $signal, $daemon['addr'], $daemon['port']);
-		}
-	}
-	
-	
-	public static function notifyProcessor($mode, $signal, $addr, $port) {
-		switch ($mode) {
-			case 'download':
-			case 'upload':
-			case 'error':
-				break;
-			
-			default:
-				throw new Exception("Invalid processor mode '$mode'");
-		}
-		
-		if (!$addr) {
-			throw new Exception("Host address not provided");
-		}
-		
-		Z_Core::debug("Notifying $mode processor $addr with signal $signal");
-		
-		$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
-		$success = socket_sendto($socket, $signal, strlen($signal), MSG_EOF, $addr, $port);
-		if (!$success) {
-			$code = socket_last_error($socket);
-			throw new Exception(socket_strerror($code));
-		}
-	}
-	
-	
-	public static function register($mode, $addr, $port) {
-		$sql = "INSERT INTO processorDaemons (mode, addr, port) VALUES (?, INET_ATON(?), ?)
-				ON DUPLICATE KEY UPDATE port=?, lastSeen=NOW()";
-		Zotero_DB::query($sql, array($mode, $addr, $port, $port));
-	}
-	
-	
-	public static function unregister($mode, $addr, $port) {
-		$sql = "DELETE FROM processorDaemons WHERE mode=? AND addr=INET_ATON(?) AND port=?";
-		Zotero_DB::query($sql, array($mode, $addr, $port));
-	}
-}
-?>
diff --git a/model/Publications.inc.php b/model/Publications.inc.php
deleted file mode 100644
index 7993774e..00000000
--- a/model/Publications.inc.php
+++ /dev/null
@@ -1,53 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2015 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_Publications {
-	// Currently unused
-	private static $rights = [
-		'CC-BY-4.0',
-		'CC-BY-ND-4.0',
-		'CC-BY-NC-SA-4.0',
-		'CC-BY-SA-4.0',
-		'CC-BY-NC-4.0',
-		'CC-BY-NC-ND-4.0',
-		'CC0'
-	];
-	
-	
-	public static function getETag($userID) {
-		$cacheKey = "publicationsETag_" . $userID;
-		$etag = Z_Core::$MC->get($cacheKey);
-		return $etag ? $etag : self::updateETag($userID);
-	}
-	
-	
-	public static function updateETag($userID) {
-		$cacheKey = "publicationsETag_" . $userID;
-		$etag = Zotero_Utilities::randomString(8, 'mixed');
-		Z_Core::$MC->set($cacheKey, $etag, 86400);
-		return $etag;
-	}
-}
diff --git a/model/Relation.inc.php b/model/Relation.inc.php
deleted file mode 100644
index 7f64585a..00000000
--- a/model/Relation.inc.php
+++ /dev/null
@@ -1,256 +0,0 @@
-<?
-/*
-    ***** 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_Relation {
-	private $id;
-	private $libraryID;
-	private $subject;
-	private $predicate;
-	private $object;
-	
-	private $loaded;
-	
-	
-	public function __get($field) {
-		if ($this->id && !$this->loaded) {
-			$this->load();
-		}
-		if ($field == 'key') {
-			return $this->getKey();
-		}
-		if (!property_exists('Zotero_Relation', $field)) {
-			throw new Exception("Zotero_Relation property '$field' doesn't exist");
-		}
-		return $this->$field;
-	}
-	
-	
-	public function __set($field, $value) {
-		switch ($field) {
-			case 'libraryID':
-			case 'id':
-				if ($value == $this->$field) {
-					return;
-				}
-				
-				if ($this->loaded) {
-					throw new Exception("Cannot set $field after relation is already loaded");
-				}
-				//$this->checkValue($field, $value);
-				$this->$field = $value;
-				return;
-		}
-		
-		if ($this->id) {
-			if (!$this->loaded) {
-				$this->load();
-			}
-		}
-		else {
-			$this->loaded = true;
-		}
-		
-		//$this->checkValue($field, $value);
-		
-		if ($this->$field != $value) {
-			//$this->prepFieldChange($field);
-			$this->$field = $value;
-		}
-	}
-	
-	
-	/**
-	 * Check if search exists in the database
-	 *
-	 * @return	bool			TRUE if the relation exists, FALSE if not
-	 */
-	public function exists() {
-		$shardID = Zotero_Shards::getByLibraryID($this->libraryID);
-		
-		if ($this->id) {
-			$sql = "SELECT COUNT(*) FROM relations WHERE relationID=?";
-			return !!Zotero_DB::valueQuery($sql, $this->id, $shardID);
-		}
-		
-		if ($this->subject && $this->predicate && $this->object) {
-			$sql = "SELECT COUNT(*) FROM relations WHERE libraryID=? AND `key`=?";
-			$params = array($this->libraryID, $this->getKey());
-			$exists = !!Zotero_DB::valueQuery($sql, $params, $shardID);
-			
-			// TEMP
-			// For linked items, check reverse order too, since client can save in reverse
-			// order when an item is dragged from a group to a personal library
-			if (!$exists && $this->predicate == Zotero_Relations::$linkedObjectPredicate
-					&& Zotero_Libraries::getType($this->libraryID) == 'user') {
-				$sql = "SELECT COUNT(*) FROM relations WHERE libraryID=? AND `key`=?";
-				$params = [
-					$this->libraryID,
-					Zotero_Relations::makeKey($this->object, $this->predicate, $this->subject)
-				];
-				return !!Zotero_DB::valueQuery($sql, $params, $shardID);
-			}
-			
-			return $exists;
-		}
-		
-		throw new Exception("ID or subject/predicate/object not set");
-	}
-	
-	
-	/*
-	 * Save the relation to the DB and return a relationID
-	 */
-	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);
-		
-		Zotero_DB::beginTransaction();
-		
-		try {
-			$shardID = Zotero_Shards::getByLibraryID($this->libraryID);
-			
-			$relationID = $this->id ? $this->id : Zotero_ID::get('relations');
-			
-			Z_Core::debug("Saving relation $relationID");
-			
-			$sql = "INSERT INTO relations
-					(relationID, libraryID, `key`, subject, predicate, object, serverDateModified)
-					VALUES (?, ?, ?, ?, ?, ?, ?)";
-			$timestamp = Zotero_DB::getTransactionTimestamp();
-			$params = array(
-				$relationID,
-				$this->libraryID,
-				$this->getKey(),
-				$this->subject,
-				$this->predicate,
-				$this->object,
-				$timestamp
-			);
-			$insertID = Zotero_DB::query($sql, $params, $shardID);
-			if (!$this->id) {
-				if (!$insertID) {
-					throw new Exception("Relation id not available after INSERT");
-				}
-				$this->id = $insertID;
-			}
-			
-			// Remove from delete log if it's there
-			$sql = "DELETE FROM syncDeleteLogKeys WHERE libraryID=? AND objectType='relation' AND `key`=?";
-			Zotero_DB::query(
-				$sql, array($this->libraryID, $this->getKey()), $shardID
-			);
-			
-			Zotero_DB::commit();
-		}
-		catch (Exception $e) {
-			Zotero_DB::rollback();
-			throw ($e);
-		}
-		return $this->id;
-	}
-	
-	
-	/**
-	 * Converts a Zotero_Relation object to a SimpleXMLElement item
-	 *
-	 * @return	SimpleXMLElement				Relation data as SimpleXML element
-	 */
-	public function toXML() {
-		if (!$this->loaded) {
-			$this->load();
-		}
-		
-		$xml = new SimpleXMLElement('<relation/>');
-		$xml['libraryID'] = $this->libraryID;
-		
-		// Swap dc:replaces for dc:isReplacedBy
-		if ($this->predicate == 'dc:replaces') {
-			$xml->subject = $this->object;
-			$xml->predicate = 'dc:isReplacedBy';
-			$xml->object = $this->subject;
-		}
-		else {
-			$xml->subject = $this->subject;
-			$xml->predicate = $this->predicate;
-			$xml->object = $this->object;
-		}
-		return $xml;
-	}
-	
-	
-	public function toJSON($asArray=false) {
-		if (!$this->loaded) {
-			$this->load();
-		}
-		
-		$arr = array();
-		$arr['subject'] = $this->subject;
-		$arr['predicate'] = $this->predicate;
-		$arr['object'] = $this->object;
-		
-		if ($asArray) {
-			return $arr;
-		}
-		
-		return Zotero_Utilities::formatJSON($arr);
-	}
-	
-	
-	private function load($allowFail=false) {
-		if (!$this->libraryID) {
-			throw new Exception("Library ID not set");
-		}
-		
-		if (!$this->id) {
-			throw new Exception("ID not set");
-		}
-		
-		//Z_Core::debug("Loading data for relation $this->id");
-		
-		$sql = "SELECT relationID AS id, libraryID, subject, object, predicate FROM relations "
-			. "WHERE relationID=?";
-		$data = Zotero_DB::rowQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
-		
-		$this->loaded = true;
-		
-		if (!$data) {
-			return;
-		}
-		
-		foreach ($data as $key=>$val) {
-			$this->$key = $val;
-		}
-	}
-	
-	
-	private function getKey() {
-		return Zotero_Relations::makeKey($this->subject, $this->predicate, $this->object);
-	}
-}
-?>
diff --git a/model/Relations.inc.php b/model/Relations.inc.php
deleted file mode 100644
index 3d33e358..00000000
--- a/model/Relations.inc.php
+++ /dev/null
@@ -1,343 +0,0 @@
-<?
-/*
-    ***** 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_Relations extends Zotero_ClassicDataObjects {
-	public static $allowedCollectionPredicates = [
-		'owl:sameAs',
-		'mendeleyDB:remoteFolderUUID'
-	];
-	
-	public static $allowedItemPredicates = [
-		'owl:sameAs',
-		'dc:replaces',
-		'dc:relation',
-		'mendeleyDB:documentUUID',
-		'mendeleyDB:remoteDocumentUUID',
-		'mendeleyDB:fileHash',
-		'mendeleyDB:relatedDocumentUUID',
-		'mendeleyDB:relatedRemoteDocumentUUID',
-		'mendeleyDB:relatedFileHash'
-	];
-	
-	public static $externalPredicates = [
-		'mendeleyDB:documentUUID',
-		'mendeleyDB:remoteDocumentUUID',
-		'mendeleyDB:fileHash',
-		'mendeleyDB:remoteFolderUUID',
-		'mendeleyDB:relatedDocumentUUID',
-		'mendeleyDB:relatedRemoteDocumentUUID',
-		'mendeleyDB:relatedFileHash'
-	];
-	
-	protected static $ZDO_object = 'relation';
-	
-	protected static $primaryFields = array(
-		'id' => 'relationID',
-		'libraryID' => '',
-		'key' => '',
-		'subject' => '',
-		'predicate' => '',
-		'object' => ''
-	);
-	
-	public static $relatedItemPredicate = 'dc:relation';
-	public static $linkedObjectPredicate = 'owl:sameAs';
-	public static $deletedItemPredicate = 'dc:replaces';
-	
-	private static $namespaces = array(
-		"dc" => 'http://purl.org/dc/elements/1.1/',
-		"owl" => 'http://www.w3.org/2002/07/owl#',
-		"mendeleyDB" => 'http://zotero.org/namespaces/mendeleyDB#'
-	);
-	
-	public static function get($libraryID, $relationID, $existsCheck=false) {
-		$relation = new Zotero_Relation;
-		$relation->libraryID = $libraryID;
-		$relation->id = $relationID;
-		return $relation;
-	}
-	
-	
-	/**
-	 * @return Zotero_Relation[]
-	 */
-	public static function getByURIs($libraryID, $subject=false, $predicate=false, $object=false) {
-		if ($predicate) {
-			$predicate = implode(':', self::_getPrefixAndValue($predicate));
-		}
-		
-		if (!is_int($libraryID)) {
-			throw new Exception('$libraryID must be an integer');
-		}
-		
-		if (!$subject && !$predicate && !$object) {
-			throw new Exception("No values provided");
-		}
-		
-		$sql = "SELECT relationID FROM relations WHERE libraryID=?";
-		$params = array($libraryID);
-		if ($subject) {
-			$sql .= " AND subject=?";
-			$params[] = $subject;
-		}
-		if ($predicate) {
-			$sql .= " AND predicate=?";
-			$params[] = $predicate;
-		}
-		if ($object) {
-			$sql .= " AND object=?";
-			$params[] = $object;
-		}
-		$rows = Zotero_DB::columnQuery(
-			$sql, $params, Zotero_Shards::getByLibraryID($libraryID)
-		);
-		if (!$rows) {
-			return array();
-		}
-		
-		$toReturn = array();
-		foreach ($rows as $id) {
-			$relation = new Zotero_Relation;
-			$relation->libraryID = $libraryID;
-			$relation->id = $id;
-			$toReturn[] = $relation;
-		}
-		return $toReturn;
-	}
-	
-	
-	public static function getSubject($libraryID, $subject=false, $predicate=false, $object=false) {
-		$subjects = array();
-		$relations = self::getByURIs($libraryID, $subject, $predicate, $object);
-		foreach ($relations as $relation) {
-			$subjects[] = $relation->subject;
-		}
-		return $subjects;
-	}
-	
-	
-	public static function getObject($libraryID, $subject=false, $predicate=false, $object=false) {
-		$objects = array();
-		$relations = self::getByURIs($libraryID, $subject, $predicate, $object);
-		foreach ($relations as $relation) {
-			$objects[] = $relation->object;
-		}
-		return $objects;
-	}
-	
-	
-	public static function makeKey($subject, $predicate, $object) {
-		return md5($subject . " " . $predicate . " " . $object);
-	}
-	
-	
-	public static function add($libraryID, $subject, $predicate, $object) {
-		$predicate = implode(':', self::_getPrefixAndValue($predicate));
-		
-		$relation = new Zotero_Relation;
-		$relation->libraryID = $libraryID;
-		$relation->subject = $subject;
-		$relation->predicate = $predicate;
-		$relation->object = $object;
-		$relation->save();
-	}
-	
-	
-	public static function eraseByURIPrefix($libraryID, $prefix, $ignorePredicates=false) {
-		Zotero_DB.beginTransaction();
-		
-		$prefix = $prefix . '%';
-		$sql = "SELECT relationID FROM relations WHERE libraryID=? AND subject LIKE ?";
-		$params = [$libraryID, $prefix];
-		if ($ignorePredicates) {
-			foreach ($ignorePredicates as $ignorePredicate) {
-				$sql .= " AND predicate != ?";
-				$params[] = $ignorePredicate;
-			}
-		}
-		$sql .= " UNION SELECT relationID FROM relations WHERE libraryID=? AND object LIKE ?";
-		$params = array_merge($params, [$libraryID, $prefix]);
-		if ($ignorePredicates) {
-			foreach ($ignorePredicates as $ignorePredicate) {
-				$sql .= " AND predicate != ?";
-				$params[] = $ignorePredicate;
-			}
-		}
-		$ids = Zotero_DB::columnQuery(
-			$sql, $params, Zotero_Shards::getByLibraryID($libraryID)
-		);
-		
-		foreach ($ids as $id) {
-			$relation = self::get($libraryID, $id);
-			Zotero_Relations::delete($libraryID, $relation->key);
-		}
-		
-		Zotero_DB::commit();
-	}
-	
-	
-	/**
-	 * Delete any relations that have the URI as either the subject
-	 * or the object
-	 */
-	public static function eraseByURI($libraryID, $uri, $ignorePredicates=false) {
-		Zotero_DB::beginTransaction();
-		
-		$sql = "SELECT relationID FROM relations WHERE libraryID=? AND subject=?";
-		$params = [$libraryID, $uri];
-		if ($ignorePredicates) {
-			foreach ($ignorePredicates as $ignorePredicate) {
-				$sql .= " AND predicate != ?";
-				$params[] = $ignorePredicate;
-			}
-		}
-		$sql .= " UNION SELECT relationID FROM relations WHERE libraryID=? AND object=?";
-		$params = array_merge($params, [$libraryID, $uri]);
-		if ($ignorePredicates) {
-			foreach ($ignorePredicates as $ignorePredicate) {
-				$sql .= " AND predicate != ?";
-				$params[] = $ignorePredicate;
-			}
-		}
-		
-		$ids = Zotero_DB::columnQuery(
-			$sql, $params, Zotero_Shards::getByLibraryID($libraryID)
-		);
-		
-		if ($ids) {
-			foreach ($ids as $id) {
-				$relation = self::get($libraryID, $id);
-				Zotero_Relations::delete($libraryID, $relation->key);
-			}
-		}
-		
-		Zotero_DB::commit();
-	}
-	
-	
-	public static function purge($libraryID) {
-		$sql = "SELECT subject FROM relations "
-			. "WHERE libraryID=? AND predicate!=? "
-			. "UNION "
-			. "SELECT object FROM relations "
-			. "WHERE libraryID=? AND predicate!=?";
-		$uris = Zotero.DB.columnQuery(
-			$sql,
-			array(
-				$libraryID,
-				self::$deletedItemPredicate,
-				$libraryID,
-				self::$deletedItemPredicate
-			),
-			Zotero_Shards::getByLibraryID($libraryID)
-		);
-		if ($uris) {
-			$prefix = Zotero_URI::getBaseURI();
-			Zotero_DB::beginTransaction();
-			foreach ($uris as $uri) {
-				// Skip URIs that don't begin with the default prefix,
-				// since they don't correspond to local items
-				if (strpos($uri, $prefix) === false) {
-					continue;
-				}
-				if (preg_match('/\/items\//', $uri) && !Zotero_URI::getURIItem($uri)) {
-					self::eraseByURI($uri);
-				}
-				if (preg_match('/\/collections\//', $uri) && !Zotero_URI::getURICollection($uri)) {
-					self::eraseByURI($uri);
-				}
-			}
-			Zotero_DB::commit();
-		}
-	}
-	
-	
-	/**
-	 * Converts a DOMElement item to a Zotero_Relation object
-	 *
-	 * @param	DOMElement			$xml		Relation data as DOM element
-	 * @param	Integer				$libraryID
-	 * @return	Zotero_Relation					Zotero relation object
-	 */
-	public static function convertXMLToRelation(DOMElement $xml, $userLibraryID) {
-		$relation = new Zotero_Relation;
-		$libraryID = $xml->getAttribute('libraryID');
-		if ($libraryID) {
-			$relation->libraryID = $libraryID;
-		}
-		else {
-			$relation->libraryID = $userLibraryID;
-		}
-		
-		$subject = $xml->getElementsByTagName('subject')->item(0)->nodeValue;
-		$predicate = $xml->getElementsByTagName('predicate')->item(0)->nodeValue;
-		$object = $xml->getElementsByTagName('object')->item(0)->nodeValue;
-		
-		if ($predicate == 'dc:isReplacedBy') {
-			$relation->subject = $object;
-			$relation->predicate = 'dc:replaces';
-			$relation->object = $subject;
-		}
-		else {
-			$relation->subject = $subject;
-			$relation->predicate = $predicate;
-			$relation->object = $object;
-		}
-		
-		return $relation;
-	}
-	
-	
-	/**
-	 * Converts a Zotero_Relation object to a SimpleXMLElement item
-	 *
-	 * @param	object				$item		Zotero_Relation object
-	 * @return	SimpleXMLElement				Relation data as SimpleXML element
-	 */
-	public static function convertRelationToXML(Zotero_Relation $relation) {
-		return $relation->toXML();
-	}
-	
-	
-	private static function _getPrefixAndValue($uri) {
-		$parts = explode(':', $uri);
-		if (isset($parts[1])) {
-			if (!isset(self::$namespaces[$parts[0]])) {
-				throw new Exception("Invalid prefix '{$parts[0]}'");
-			}
-			return $parts;
-		}
-		
-		foreach (self::$namespaces as $prefix => $val) {
-			if (strpos($uri, $val) === 0) {
-				$value = substr($uri, strlen($val) - 1);
-				return array($prefix, $value);
-			}
-		}
-		throw new Exception("Invalid namespace in URI '$uri'");
-	}
-}
-?>
diff --git a/model/Results.inc.php b/model/Results.inc.php
deleted file mode 100644
index 3bb3461a..00000000
--- a/model/Results.inc.php
+++ /dev/null
@@ -1,124 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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_Results {
-	private $requestParams;
-	private $successful = [];
-	private $success = []; // Deprecated
-	private $unchanged = [];
-	private $failed = [];
-	
-	public function __construct(array $requestParams) {
-		$this->requestParams = $requestParams;
-	}
-	
-	
-	public function addSuccessful($index, $obj) {
-		if ($this->requestParams['v'] >= 3) {
-			$this->successful[$index] = $obj;
-		}
-		// Deprecated
-		$this->success[$index] = $obj['key'];
-	}
-	
-	
-	public function addUnchanged($index, $key) {
-		$this->unchanged[$index] = $key;
-	}
-	
-	
-	public function addFailure($index, $key, Exception $e) {
-		if (isset($this->failed[$index])) {
-			throw new Exception("Duplicate index '$index' for failure with key '$key'");
-		}
-		$this->failed[$index] = Zotero_Errors::parseException($e);
-		$this->failed[$index]['key'] = $key;
-		return $this->failed[$index];
-	}
-	
-	
-	public function generateReport() {
-		if ($this->requestParams['v'] >= 3) {
-			$report = [
-				'successful' => new stdClass(),
-				'success' => new stdClass(),
-				'unchanged' => new stdClass(),
-				'failed' => new stdClass()
-			];
-		}
-		else {
-			$report = [
-				'success' => new stdClass(),
-				'unchanged' => new stdClass(),
-				'failed' => new stdClass()
-			];
-		}
-		foreach ($this->successful as $index => $key) {
-			$report['successful']->$index = $key;
-		}
-		// Deprecated
-		foreach ($this->success as $index => $key) {
-			$report['success']->$index = $key;
-		}
-		foreach ($this->unchanged as $index => $key) {
-			$report['unchanged']->$index = $key;
-		}
-		foreach ($this->failed as $index => $error) {
-			$obj = [
-				'key' => $error['key'],
-				'code' => $error['code'],
-				'message' => htmlspecialchars($error['message'])
-			];
-			if (isset($error['data'])) {
-				$obj['data'] = $error['data'];
-			}
-			// If key is blank, don't include it
-			if ($obj['key'] === '') {
-				unset($obj['key']);
-			}
-			
-			$report['failed']->$index = $obj;
-		}
-		return $report;
-	}
-	
-	
-	public function generateLogMessage() {
-		if (!$this->failed) {
-			return "";
-		}
-		
-		$str = "";
-		foreach ($this->failed as $error) {
-			if (!$error['log']) {
-				continue;
-			}
-			$str .= "Code: " . $error['code'] . "\n";
-			$str .= "Key: " . $error['key'] . "\n";
-			$str .= $error['exception'] . "\n\n";
-		}
-		return $str;
-	}
-}
diff --git a/model/Search.inc.php b/model/Search.inc.php
deleted file mode 100644
index 556de08b..00000000
--- a/model/Search.inc.php
+++ /dev/null
@@ -1,357 +0,0 @@
-<?
-/*
-    ***** 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_Search extends Zotero_DataObject {
-	protected $objectType = 'search';
-	protected $dataTypesExtended = ['conditions'];
-	
-	protected $_name;
-	protected $_dateAdded;
-	protected $_dateModified;
-	
-	private $conditions = array();
-	
-	
-	/**
-	 * Check if search 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 savedSearches WHERE searchID=?";
-		return !!Zotero_DB::valueQuery($sql, $this->id, Zotero_Shards::getByLibraryID($this->libraryID));
-	}
-	
-	
-	/*
-	 * Save the search to the DB and return a savedSearchID
-	 *
-	 * For new searches, setName() must be called before saving
-	 */
-	public function save($userID=false) {
-		if (!$this->_libraryID) {
-			throw new Exception("Library ID must be set before saving");
-		}
-		
-		Zotero_Searches::editCheck($this, $userID);
-		
-		if (!$this->hasChanged()) {
-			Z_Core::debug("Search $this->_id has not changed");
-			return false;
-		}
-		
-		if (!isset($this->_name) || $this->_name === '') {
-			throw new Exception("Name not provided for saved search");
-		}
-		
-		$shardID = Zotero_Shards::getByLibraryID($this->_libraryID);
-		
-		Zotero_DB::beginTransaction();
-		
-		$env = [];
-		$isNew = $env['isNew'] = !$this->_id || !$this->exists();
-		
-		try {
-			$searchID = $env['id'] = $this->_id ? $this->_id : Zotero_ID::get('savedSearches');
-			
-			Z_Core::debug("Saving search $this->_id");
-			$key = $env['key'] = $this->_key ? $this->_key : Zotero_ID::getKey();
-			
-			$fields = "searchName=?, libraryID=?, `key`=?, dateAdded=?, dateModified=?,
-						serverDateModified=?, version=?";
-			$timestamp = Zotero_DB::getTransactionTimestamp();
-			$params = array(
-				$this->_name,
-				$this->_libraryID,
-				$key,
-				$this->_dateAdded ? $this->_dateAdded : $timestamp,
-				$this->_dateModified ? $this->_dateModified : $timestamp,
-				$timestamp,
-				Zotero_Libraries::getUpdatedVersion($this->_libraryID)
-			);
-			$shardID = Zotero_Shards::getByLibraryID($this->_libraryID);
-			
-			if ($isNew) {
-				$sql = "INSERT INTO savedSearches SET searchID=?, $fields";
-				$stmt = Zotero_DB::getStatement($sql, true, $shardID);
-				Zotero_DB::queryFromStatement($stmt, array_merge(array($searchID), $params));
-				
-				// Remove from delete log if it's there
-				$sql = "DELETE FROM syncDeleteLogKeys WHERE libraryID=? AND objectType='search' AND `key`=?";
-				Zotero_DB::query($sql, array($this->_libraryID, $key), $shardID);
-			}
-			else {
-				$sql = "UPDATE savedSearches SET $fields WHERE searchID=?";
-				$stmt = Zotero_DB::getStatement($sql, true, $shardID);
-				Zotero_DB::queryFromStatement($stmt, array_merge($params, array($searchID)));
-			}
-			
-			if (!empty($this->changed['conditions'])) {
-				if (!$isNew) {
-					$sql = "DELETE FROM savedSearchConditions WHERE searchID=?";
-					Zotero_DB::query($sql, $searchID, $shardID);
-				}
-				
-				foreach ($this->conditions as $searchConditionID => $condition) {
-					$sql = "INSERT INTO savedSearchConditions (searchID,
-							searchConditionID, `condition`, mode, operator,
-							value, required) VALUES (?,?,?,?,?,?,?)";
-					$sqlParams = [
-						$searchID,
-						// Index search conditions from 1
-						$searchConditionID + 1,
-						$condition['condition'],
-						$condition['mode'] ? $condition['mode'] : '',
-						$condition['operator'] ? $condition['operator'] : '',
-						$condition['value'] ? $condition['value'] : '',
-						!empty($condition['required']) ? 1 : 0
-					];
-					try {
-						Zotero_DB::query($sql, $sqlParams, $shardID);
-					}
-					catch (Exception $e) {
-						$msg = $e->getMessage();
-						if (strpos($msg, "Data too long for column 'value'") !== false) {
-							throw new Exception("=Value '" . mb_substr($condition['value'], 0, 75) . "…' too long in saved search '" . $this->_name . "'");
-						}
-						throw ($e);
-					}
-				}
-			}
-			
-			Zotero_DB::commit();
-		}
-		catch (Exception $e) {
-			Zotero_DB::rollback();
-			throw ($e);
-		}
-		
-		$this->finalizeSave($env);
-		
-		return $isNew ? $this->_id : true;
-	}
-	
-	
-	public function updateConditions($conditions) {
-		$this->loadPrimaryData();
-		$this->loadConditions();
-		
-		for ($i = 1, $len = sizeOf($conditions); $i <= $len; $i++) {
-			// Compare existing values to new values
-			if (isset($this->conditions[$i])) {
-				if ($this->conditions[$i]['condition'] == $conditions[$i - 1]['condition']
-						&& $this->conditions[$i]['mode'] == $conditions[$i - 1]['mode']
-						&& $this->conditions[$i]['operator'] == $conditions[$i - 1]['operator']
-						&& $this->conditions[$i]['value'] == $conditions[$i - 1]['value']) {
-					continue;
-				}
-			}
-			$this->changed['conditions'] = true;
-		}
-		if (!empty($this->changed['conditions']) || sizeOf($this->conditions) > $conditions) {
-			$this->conditions = $conditions;
-		}
-		else {
-			Z_Core::debug("Conditions have not changed for search $this->id");
-		}
-	}
-	
-	
-	/**
-	  * Returns an array with 'condition', 'mode', 'operator', and 'value'
-	  * for the given searchConditionID
-	  */
-	public function getSearchCondition($searchConditionID) {
-		$this->loadPrimaryData();
-		$this->loadConditions();
-		
-		return isset($this->conditions[$searchConditionID])
-			? $this->conditions[$searchConditionID] : false;
-	}
-	
-	
-	/**
-	  * Returns a multidimensional array of conditions/mode/operator/value sets
-	  * used in the search, indexed by searchConditionID
-	  */
-	public function getSearchConditions() {
-		$this->loadPrimaryData();
-		$this->loadConditions();
-		
-		return $this->conditions;
-	}
-	
-	
-	public function toResponseJSON($requestParams=[], Zotero_Permissions $permissions) {
-		$t = microtime(true);
-		
-		$this->loadPrimaryData();
-		
-		$json = [
-			'key' => $this->key,
-			'version' => $this->version,
-			'library' => Zotero_Libraries::toJSON($this->libraryID)
-		];
-		
-		// 'links'
-		$json['links'] = [
-			'self' => [
-				'href' => Zotero_API::getSearchURI($this),
-				'type' => 'application/json'
-			]/*,
-			'alternate' => [
-				'href' => Zotero_URI::getSearchURI($this, true),
-				'type' => 'text/html'
-			]*/
-		];
-
-		
-		// 'include'
-		$include = $requestParams['include'];
-		foreach ($include as $type) {
-			if ($type == 'data') {
-				$json[$type] = $this->toJSON($requestParams);
-			}
-		}
-		
-		return $json;
-	}
-	
-	
-	public function toJSON(array $requestParams=[]) {
-		$this->loadPrimaryData();
-		$this->loadConditions();
-		
-		if ($requestParams['v'] >= 3) {
-			$arr['key'] = $this->key;
-			$arr['version'] = $this->version;
-		}
-		else {
-			$arr['searchKey'] = $this->key;
-			$arr['searchVersion'] = $this->version;
-		}
-		$arr['name'] = $this->name;
-		$arr['conditions'] = array();
-		
-		foreach ($this->conditions as $condition) {
-			$arr['conditions'][] = array(
-				'condition' => $condition['condition']
-					. ($condition['mode'] ? "/{$condition['mode']}" : ""),
-				'operator' => $condition['operator'],
-				'value' => $condition['value']
-			);
-		}
-		
-		return $arr;
-	}
-	
-	
-	/**
-	 * Generate a SimpleXMLElement Atom object for the search
-	 *
-	 * @param array $queryParams
-	 * @return SimpleXMLElement
-	 */
-	public function toAtom($queryParams) {
-		$this->loadPrimaryData();
-		$this->loadConditions();
-		
-		// TEMP: multi-format support
-		if (!empty($queryParams['content'])) {
-			$content = $queryParams['content'];
-		}
-		else {
-			$content = array('none');
-		}
-		$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 ? $this->name : '[Untitled]';
-		
-		$author = $xml->addChild('author');
-		// TODO: group item creator
-		$author->name = Zotero_Libraries::getName($this->libraryID);
-		$author->uri = Zotero_URI::getLibraryURI($this->libraryID, true);
-		
-		$xml->id = Zotero_URI::getSearchURI($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::getSearchURI($this);
-		
-		$xml->addChild('zapi:key', $this->key, Zotero_Atom::$nsZoteroAPI);
-		$xml->addChild('zapi:version', $this->version, Zotero_Atom::$nsZoteroAPI);
-		
-		if ($content == 'json') {
-			$xml->content['type'] = 'application/json';
-			$xml->content = Zotero_Utilities::formatJSON($this->toJSON($queryParams));
-		}
-		
-		return $xml;
-	}
-	
-	
-	protected function loadConditions($reload = false) {
-		if (!$this->identified) return;
-		if ($this->loaded['conditions'] && !$reload) return;
-		
-		$sql = "SELECT * FROM savedSearchConditions
-				WHERE searchID=? ORDER BY searchConditionID";
-		$conditions = Zotero_DB::query(
-			$sql, $this->_id, Zotero_Shards::getByLibraryID($this->_libraryID)
-		);
-		
-		$this->conditions = [];
-		foreach ($conditions as $condition) {
-			$searchConditionID = $condition['searchConditionID'];
-			$this->conditions[$searchConditionID] = [
-				'id' => $searchConditionID,
-				'condition' => $condition['condition'],
-				'mode' => $condition['mode'],
-				'operator' => $condition['operator'],
-				'value' => $condition['value'],
-				'required' => $condition['required']
-			];
-		}
-		
-		$this->loaded['conditions'] = true;
-		$this->clearChanged('conditions');
-	}
-}
-?>
diff --git a/model/Searches.inc.php b/model/Searches.inc.php
deleted file mode 100644
index 0d0c0fe1..00000000
--- a/model/Searches.inc.php
+++ /dev/null
@@ -1,365 +0,0 @@
-<?
-/*
-    ***** 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_Searches {
-	use Zotero_DataObjects;
-	
-	private static $objectType = 'search';
-	private static $_table = 'savedSearches';
-	private static $primaryDataSQLParts = [
-		'id' => 'O.searchID',
-		'name' => 'O.searchName',
-		'libraryID' => 'O.libraryID',
-		'key' => 'O.key',
-		'dateAdded' => 'O.dateAdded',
-		'dateModified' => 'O.dateModified',
-		'version' => 'O.version'
-	];
-	
-	
-	public static function search($libraryID, $params) {
-		$results = array('results' => array(), 'total' => 0);
-		
-		$shardID = Zotero_Shards::getByLibraryID($libraryID);
-		
-		$sql = "SELECT SQL_CALC_FOUND_ROWS DISTINCT ";
-		if ($params['format'] == 'keys') {
-			$sql .= "`key`";
-		}
-		else if ($params['format'] == 'versions') {
-			$sql .= "`key`, version";
-		}
-		else {
-			$sql .= "searchID";
-		}
-		$sql .= " FROM savedSearches WHERE libraryID=? ";
-		$sqlParams = array($libraryID);
-		
-		// Pass a list of searchIDs, for when the initial search is done via SQL
-		$searchIDs = !empty($params['searchIDs'])
-			? $params['searchIDs'] : array();
-		// Or keys, for the searchKey parameter
-		$searchKeys = $params['searchKey'];
-		
-		if (!empty($params['since'])) {
-			$sql .= "AND version > ? ";
-			$sqlParams[] = $params['since'];
-		}
-		
-		// TEMP: for sync transition
-		if (!empty($params['sincetime'])) {
-			$sql .= "AND serverDateModified >= FROM_UNIXTIME(?) ";
-			$sqlParams[] = $params['sincetime'];
-		}
-		
-		if ($searchIDs) {
-			$sql .= "AND searchID IN ("
-					. implode(', ', array_fill(0, sizeOf($searchIDs), '?'))
-					. ") ";
-			$sqlParams = array_merge($sqlParams, $searchIDs);
-		}
-		
-		if ($searchKeys) {
-			$sql .= "AND `key` IN ("
-					. implode(', ', array_fill(0, sizeOf($searchKeys), '?'))
-					. ") ";
-			$sqlParams = array_merge($sqlParams, $searchKeys);
-		}
-		
-		if (!empty($params['sort'])) {
-			switch ($params['sort']) {
-			case 'title':
-				$orderSQL = 'searchName';
-				break;
-			
-			case 'searchKeyList':
-				$orderSQL = "FIELD(`key`,"
-						. implode(',', array_fill(0, sizeOf($searchKeys), '?')) . ")";
-				$sqlParams = array_merge($sqlParams, $searchKeys);
-				break;
-			
-			default:
-				$orderSQL = $params['sort'];
-			}
-			
-			$sql .= "ORDER BY $orderSQL";
-			if (!empty($params['direction'])) {
-				$sql .= " {$params['direction']}";
-			}
-			$sql .= ", ";
-		}
-		$sql .= "version " . (!empty($params['direction']) ? $params['direction'] : "ASC")
-			. ", searchID " . (!empty($params['direction']) ? $params['direction'] : "ASC") . " ";
-		
-		if (!empty($params['limit'])) {
-			$sql .= "LIMIT ?, ?";
-			$sqlParams[] = $params['start'] ? $params['start'] : 0;
-			$sqlParams[] = $params['limit'];
-		}
-		
-		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') {
-				$results['results'] = $rows;
-			}
-			else if ($params['format'] == 'versions') {
-				foreach ($rows as $row) {
-					$results['results'][$row['key']] = $row['version'];
-				}
-			}
-			else {
-				$searches = array();
-				foreach ($rows as $id) {
-					$searches[] = self::get($libraryID, $id);
-				}
-				$results['results'] = $searches;
-			}
-		}
-		
-		return $results;
-	}
-	
-	
-	/**
-	 * Converts a SimpleXMLElement item to a Zotero_Search object
-	 *
-	 * @param	SimpleXMLElement	$xml		Search data as SimpleXML element
-	 * @return	Zotero_Search					Zotero search object
-	 */
-	public static function convertXMLToSearch(SimpleXMLElement $xml) {
-		$search = new Zotero_Search;
-		$search->libraryID = (int) $xml['libraryID'];
-		$search->key = (string) $xml['key'];
-		$search->name = (string) $xml['name'];
-		$search->dateAdded = (string) $xml['dateAdded'];
-		$search->dateModified = (string) $xml['dateModified'];
-		
-		$conditions = array();
-		foreach($xml->condition as $condition) {
-			$conditions[] = array(
-				'condition' => (string) $condition['condition'],
-				'mode' => (string) $condition['mode'],
-				'operator' => (string) $condition['operator'],
-				'value' => (string) $condition['value']
-			);
-		}
-		$search->updateConditions($conditions);
-		
-		return $search;
-	}
-	
-	
-	/**
-	 * Converts a Zotero_Search object to a SimpleXMLElement item
-	 *
-	 * @param	object				$item		Zotero_Search object
-	 * @return	SimpleXMLElement					Search data as SimpleXML element
-	 */
-	public static function convertSearchToXML(Zotero_Search $search) {
-		$xml = new SimpleXMLElement('<search/>');
-		$xml['libraryID'] = $search->libraryID;
-		$xml['key'] = $search->key;
-		$xml['name'] = $search->name;
-		$xml['dateAdded'] = $search->dateAdded;
-		$xml['dateModified'] = $search->dateModified;
-		
-		$conditions = $search->getSearchConditions();
-		
-		if ($conditions) {
-			foreach($conditions as $condition) {
-				$c = $xml->addChild('condition');
-				$c['id'] = $condition['id'];
-				$c['condition'] = $condition['condition'];
-				if ($condition['mode']) {
-					$c['mode'] = $condition['mode'];
-				}
-				$c['operator'] = $condition['operator'];
-				$c['value'] = $condition['value'];
-				if ($condition['required']) {
-					$c['required'] = "1";
-				}
-			}
-		}
-		
-		return $xml;
-	}
-	
-	
-	/**
-	 * @param Zotero_Searches $search The search object to update;
-	 *                                this should be either an existing
-	 *                                search or a new search
-	 *                                with a library assigned.
-	 * @param object $json Search data to write
-	 * @param boolean $requireVersion See Zotero_API::checkJSONObjectVersion()
-	 * @return bool True if the search was changed, false otherwise
-	 */
-	public static function updateFromJSON(Zotero_Search $search,
-	                                      $json,
-	                                      $requestParams,
-	                                      $userID,
-	                                      $requireVersion=0,
-	                                      $partialUpdate=false) {
-		$json = Zotero_API::extractEditableJSON($json);
-		$exists = Zotero_API::processJSONObjectKey($search, $json, $requestParams);
-		Zotero_API::checkJSONObjectVersion($search, $json, $requestParams, $requireVersion);
-		self::validateJSONSearch($json, $requestParams, $partialUpdate && $exists);
-		
-		if (isset($json->name)) {
-			$search->name = $json->name;
-		}
-		
-		if (isset($json->conditions)) {
-			$conditions = [];
-			foreach ($json->conditions as $condition) {
-				$newCondition = get_object_vars($condition);
-				// Parse 'mode' (e.g., '/regexp') out of condition name
-				if (preg_match('/(.+)\/(.+)/', $newCondition['condition'], $matches)) {
-					$newCondition['condition'] = $matches[1];
-					$newCondition['mode'] = $matches[2];
-				}
-				else {
-					$newCondition['mode'] = "";
-				}
-				$conditions[] = $newCondition;
-			}
-			$search->updateConditions($conditions);
-		}
-		return !!$search->save();
-	}
-	
-	
-	private static function validateJSONSearch($json, $requestParams, $partialUpdate=false) {
-		if (!is_object($json)) {
-			throw new Exception('$json must be a decoded JSON object');
-		}
-		
-		if ($partialUpdate) {
-			$requiredProps = [];
-		}
-		else {
-			$requiredProps = ['name', 'conditions'];
-		}
-		foreach ($requiredProps as $prop) {
-			if (!isset($json->$prop)) {
-				throw new Exception("'$prop' property not provided", Z_ERROR_INVALID_INPUT);
-			}
-		}
-		foreach ($json as $key => $val) {
-			switch ($key) {
-				// Handled by Zotero_API::checkJSONObjectVersion()
-				case 'key':
-				case 'version':
-				case 'searchKey':
-				case 'searchVersion':
-					break;
-				
-				case 'name':
-					if (!is_string($val)) {
-						throw new Exception("'name' must be a string", Z_ERROR_INVALID_INPUT);
-					}
-					
-					if ($val === "") {
-						throw new Exception("Search name cannot be empty", Z_ERROR_INVALID_INPUT);
-					}
-					
-					if (mb_strlen($val) > 255) {
-						throw new Exception("Search name cannot be longer than 255 characters", Z_ERROR_FIELD_TOO_LONG);
-					}
-					break;
-					
-				case 'conditions':
-					if (!is_array($val)) {
-						throw new Exception("'conditions' must be an array (" . gettype($val) . ")", Z_ERROR_INVALID_INPUT);
-					}
-					if (empty($val)) {
-						throw new Exception("'conditions' cannot be empty", Z_ERROR_INVALID_INPUT);
-					}
-					
-					foreach ($val as $condition) {
-						$requiredProps = ['condition', 'operator', 'value'];
-						foreach ($requiredProps as $prop) {
-							if (!isset($condition->$prop)) {
-								throw new Exception("'$prop' property not provided for search condition", Z_ERROR_INVALID_INPUT);
-							}
-						}
-						
-						foreach ($condition as $conditionKey => $conditionVal) {
-							if (!is_string($conditionVal)) {
-								throw new Exception("'$conditionKey' must be a string", Z_ERROR_INVALID_INPUT);
-							}
-							
-							switch ($conditionKey) {
-							case 'condition':
-								if ($conditionVal === "") {
-									throw new Exception("Search condition cannot be empty", Z_ERROR_INVALID_INPUT);
-								}
-								$maxLen = 50;
-								if (strlen($conditionVal) > $maxLen) {
-									throw new Exception("Search condition cannot be longer than $maxLen characters", Z_ERROR_INVALID_INPUT);
-								}
-								break;
-								
-							case 'operator':
-								if ($conditionVal === "") {
-									throw new Exception("Search operator cannot be empty", Z_ERROR_INVALID_INPUT);
-								}
-								$maxLen = 25;
-								if (strlen($conditionVal) > $maxLen) {
-									throw new Exception("Search operator cannot be longer than $maxLen characters", Z_ERROR_INVALID_INPUT);
-								}
-								break;
-								
-							case 'value':
-								$maxLen = 255;
-								if (strlen($conditionVal) > $maxLen) {
-									throw new Exception("Search operator cannot be longer than $maxLen characters", Z_ERROR_INVALID_INPUT);
-								}
-								break;
-								
-							default:
-								throw new Exception("Invalid property '$conditionKey' for search condition", Z_ERROR_INVALID_INPUT);
-							}
-						}
-					}
-					break;
-				
-				default:
-					throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT);
-			}
-		}
-	}
-}
-
-Zotero_Searches::init();
diff --git a/model/Setting.inc.php b/model/Setting.inc.php
deleted file mode 100644
index df68a98c..00000000
--- a/model/Setting.inc.php
+++ /dev/null
@@ -1,237 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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_Setting {
-	private $libraryID;
-	private $name;
-	private $value;
-	private $version = 0;
-	
-	private $loaded;
-	private $changed;
-	
-	
-	public function __get($prop) {
-		if ($this->name && !$this->loaded) {
-			$this->load();
-		}
-		
-		if (!property_exists('Zotero_Setting', $prop)) {
-			throw new Exception("Zotero_Setting property '$prop' doesn't exist");
-		}
-		
-		return $this->$prop;
-	}
-	
-	
-	public function __set($prop, $value) {
-		switch ($prop) {
-			case 'version':
-				throw new Exception("Cannot modify version");
-			
-			case 'libraryID':
-			case 'name':
-				if ($this->loaded) {
-					throw new Exception("Cannot set $prop after setting is already loaded");
-				}
-				$this->checkProperty($prop, $value);
-				$this->$prop = $value;
-				return;
-		}
-		
-		if ($this->name) {
-			if (!$this->loaded) {
-				$this->load();
-			}
-		}
-		else {
-			$this->loaded = true;
-		}
-		
-		$this->checkProperty($prop, $value);
-		
-		if ($this->$prop != $value) {
-			//Z_Core::debug("Setting property '$prop' has changed from '{$this->$prop}' to '$value'");
-			$this->changed = true;
-			$this->$prop = $value;
-		}
-	}
-	
-	
-	/**
-	 * Check if setting exists in the database
-	 *
-	 * @return bool TRUE if the setting exists, FALSE if not
-	 */
-	public function exists() {
-		$sql = "SELECT COUNT(*) FROM settings WHERE libraryID=? AND name=?";
-		return !!Zotero_DB::valueQuery(
-			$sql,
-			array($this->libraryID, $this->name),
-			Zotero_Shards::getByLibraryID($this->libraryID)
-		);
-	}
-	
-	
-	/**
-	 * Save the setting to the DB
-	 */
-	public function save($userID=false) {
-		if (!$this->libraryID) {
-			throw new Exception("libraryID not set");
-		}
-		if (!isset($this->name) || $this->name === '') {
-			throw new Exception("Setting name not provided");
-		}
-		
-		try {
-			Zotero_Settings::editCheck($this, $userID);
-		}
-		// TEMP: Ignore this for now, since there seems to be a client bug as of 4.0.17 that can
-		// cause settings from deleted libraries to remain
-		catch (Exception $e) {
-			error_log("WARNING: " . $e);
-			return false;
-		}
-		
-		if (!$this->changed) {
-			Z_Core::debug("Setting $this->libraryID/$this->name has not changed");
-			return false;
-		}
-		
-		$shardID = Zotero_Shards::getByLibraryID($this->libraryID);
-		
-		Zotero_DB::beginTransaction();
-		
-		$isNew = !$this->exists();
-		
-		try {
-			Z_Core::debug("Saving setting $this->libraryID/$this->name");
-			
-			$params = array(
-				json_encode($this->value),
-				Zotero_Libraries::getUpdatedVersion($this->libraryID),
-				Zotero_DB::getTransactionTimestamp()
-			);
-			$params = array_merge(array($this->libraryID, $this->name), $params, $params);
-			
-			$shardID = Zotero_Shards::getByLibraryID($this->libraryID);
-			
-			$sql = "INSERT INTO settings (libraryID, name, value, version, lastUpdated) "
-				. "VALUES (?, ?, ?, ?, ?) "
-				. "ON DUPLICATE KEY UPDATE value=?, version=?, lastUpdated=?";
-			Zotero_DB::query($sql, $params, $shardID);
-			
-			// Remove from delete log if it's there
-			$sql = "DELETE FROM syncDeleteLogKeys WHERE libraryID=? AND objectType='setting' AND `key`=?";
-			Zotero_DB::query($sql, array($this->libraryID, $this->name), $shardID);
-			
-			Zotero_DB::commit();
-		}
-		catch (Exception $e) {
-			Zotero_DB::rollback();
-			throw ($e);
-		}
-		
-		return true;
-	}
-	
-	
-	public function toJSON($asArray=false, $requestParams=array()) {
-		if (!$this->loaded) {
-			$this->load();
-		}
-		
-		$arr = array(
-			'value' => $this->value,
-			'version' => $this->version
-		);
-		
-		if ($asArray) {
-			return $arr;
-		}
-		
-		return Zotero_Utilities::formatJSON($arr);
-	}
-	
-	
-	private function load() {
-		$libraryID = $this->libraryID;
-		$name = $this->name;
-		
-		Z_Core::debug("Loading data for setting $libraryID/$name");
-		
-		if (!$libraryID) {
-			throw new Exception("Library ID not set");
-		}
-		
-		if (!$name) {
-			throw new Exception("Name not set");
-		}
-		
-		$row = Zotero_Settings::getPrimaryDataByKey($libraryID, $name);
-		
-		$this->loaded = true;
-		
-		if (!$row) {
-			return;
-		}
-		
-		foreach ($row as $key => $val) {
-			if ($key == 'value') {
-				$val = json_decode($val);
-			}
-			$this->$key = $val;
-		}
-	}
-	
-	
-	private function checkProperty($prop, $val) {
-		if (!property_exists($this, $prop)) {
-			throw new Exception("Invalid property '$prop'");
-		}
-		
-		// Data validation
-		switch ($prop) {
-			case 'libraryID':
-				if (!Zotero_Utilities::isPosInt($val)) {
-					throw new Exception("Invalid '$prop' value '$value'");
-				}
-				break;
-			
-			case 'name':
-				if (!in_array($val, Zotero_Settings::$allowedSettings)) {
-					throw new Exception("Invalid setting '$val'", Z_ERROR_INVALID_INPUT);
-				}
-				break;
-			
-			case 'value':
-				Zotero_Settings::checkSettingValue($this->name, $val);
-				break;
-		}
-	}
-}
-?>
diff --git a/model/Settings.inc.php b/model/Settings.inc.php
deleted file mode 100644
index a683f32e..00000000
--- a/model/Settings.inc.php
+++ /dev/null
@@ -1,274 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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_Settings extends Zotero_ClassicDataObjects {
-	public static $MAX_VALUE_LENGTH = 20000;
-	
-	public static $allowedSettings = ['feeds', 'tagColors'];
-	
-	protected static $ZDO_object = 'setting';
-	protected static $ZDO_key = 'name';
-	protected static $ZDO_id = 'name';
-	protected static $ZDO_timestamp = 'lastUpdated';
-	
-	protected static $primaryFields = array(
-		'libraryID' => '',
-		'name' => '',
-		'value' => '',
-		'version' => ''
-	);
-	
-	public static function search($libraryID, $params) {
-		// Default empty library
-		if ($libraryID === 0) {
-			return [];
-		}
-		
-		$sql = "SELECT name FROM settings WHERE libraryID=?";
-		$sqlParams = [$libraryID];
-		
-		if (!empty($params['since'])) {
-			$sql .= " AND version > ? ";
-			$sqlParams[] = $params['since'];
-		}
-		
-		// TEMP: for sync transition
-		if (!empty($params['sincetime'])) {
-			$sql .= " AND lastUpdated >= FROM_UNIXTIME(?) ";
-			$sqlParams[] = $params['sincetime'];
-		}
-		
-		$names = Zotero_DB::columnQuery($sql, $sqlParams, Zotero_Shards::getByLibraryID($libraryID));
-		if (!$names) {
-			$names = array();
-		}
-		
-		$settings = array();
-		foreach ($names as $name) {
-			$setting = new Zotero_Setting;
-			$setting->libraryID = $libraryID;
-			$setting->name = $name;
-			$settings[] = $setting;
-		}
-		return $settings;
-	}
-	
-	
-	
-	/**
-	 * Converts a DOMElement item to a Zotero_Setting object
-	 *
-	 * @param DOMElement $xml Setting data as DOMElement
-	 * @return Zotero_Setting Setting object
-	 */
-	public static function convertXMLToSetting(DOMElement $xml) {
-		$libraryID = (int) $xml->getAttribute('libraryID');
-		$name = (string) $xml->getAttribute('name');
-		$setting = self::getByLibraryAndKey($libraryID, $name);
-		if (!$setting) {
-			$setting = new Zotero_Setting;
-			$setting->libraryID = $libraryID;
-			$setting->name = $name;
-		}
-		$setting->value = json_decode((string) $xml->nodeValue);
-		return $setting;
-	}
-	
-	
-	/**
-	 * Converts a Zotero_Setting object to a SimpleXMLElement item
-	 *
-	 * @param Zotero_Setting $item Zotero_Setting object
-	 * @return DOMElement
-	 */
-	public static function convertSettingToXML(Zotero_Setting $setting, DOMDocument $doc) {
-		$xmlSetting = $doc->createElement('setting');
-		$xmlSetting->setAttribute('libraryID', $setting->libraryID);
-		$xmlSetting->setAttribute('name', $setting->name);
-		$xmlSetting->setAttribute('version', $setting->version);
-		$xmlSetting->appendChild($doc->createTextNode(json_encode($setting->value)));
-		return $xmlSetting;
-	}
-	
-	
-	/**
-	 * @param Zotero_Setting $setting The setting object to update;
-	 *                                this should be either an existing
-	 *                                setting or a new setting
-	 *                                with a library and name assigned.
-	 * @param object $json Setting data to write
-	 * @param boolean [$requireVersion=0] See Zotero_API::checkJSONObjectVersion()
-	 * @return boolean True if the setting was changed, false otherwise
-	 */
-	public static function updateFromJSON(Zotero_Setting $setting,
-	                                      $json,
-	                                      $requestParams,
-	                                      $userID,
-	                                      $requireVersion=0) {
-		self::validateJSONObject($setting->name, $json, $requestParams);
-		Zotero_API::checkJSONObjectVersion(
-			$setting, $json, $requestParams, $requireVersion
-		);
-		
-		$changed = false;
-		
-		if (!Zotero_DB::transactionInProgress()) {
-			Zotero_DB::beginTransaction();
-			$transactionStarted = true;
-		}
-		else {
-			$transactionStarted = false;
-		}
-		
-		$setting->value = $json->value;
-		$changed = $setting->save() || $changed;
-		
-		if ($transactionStarted) {
-			Zotero_DB::commit();
-		}
-		
-		return $changed;
-	}
-	
-	
-	private static function validateJSONObject($name, $json, $requestParams) {
-		if (!is_object($json)) {
-			throw new Exception('$json must be a decoded JSON object');
-		}
-		
-		$requiredProps = array('value');
-		
-		if (!in_array($name, self::$allowedSettings)) {
-			throw new Exception("Invalid setting '$name'", Z_ERROR_INVALID_INPUT);
-		}
-		
-		foreach ($requiredProps as $prop) {
-			if (!isset($json->$prop)) {
-				throw new Exception("'$prop' property not provided", Z_ERROR_INVALID_INPUT);
-			}
-		}
-		
-		foreach ($json as $key=>$val) {
-			switch ($key) {
-				// Handled by Zotero_API::checkJSONObjectVersion()
-				case 'version':
-					break;
-				
-				case 'value':
-					self::checkSettingValue($name, $val);
-					break;
-					
-				default:
-					throw new Exception("Invalid property '$key'", Z_ERROR_INVALID_INPUT);
-			}
-		}
-	}
-	
-	
-	public static function updateMultipleFromJSON($json, $requestParams, $libraryID, $userID, $requireVersion, $parent=null) {
-		self::validateMultiObjectJSON($json, $requestParams);
-		
-		Zotero_DB::beginTransaction();
-		
-		$changed = false;
-		foreach ($json as $name => $jsonObject) {
-			if (!is_object($jsonObject)) {
-				throw new Exception(
-					"Invalid property '$name'; expected JSON setting object",
-					Z_ERROR_INVALID_INPUT
-				);
-			}
-			
-			$obj = new Zotero_Setting;
-			$obj->libraryID = $libraryID;
-			$obj->name = $name;
-			$changed = static::updateFromJSON(
-				$obj, $jsonObject, $requestParams, $requireVersion
-			) || $changed;
-		}
-		
-		Zotero_DB::commit();
-		
-		return $changed;
-	}
-	
-	
-	public static function checkSettingValue($setting, $value) {
-		if (mb_strlen(is_string($value) ? $value : json_encode($value)) > self::$MAX_VALUE_LENGTH) {
-			throw new Exception("'value' cannot be longer than "
-				. self::$MAX_VALUE_LENGTH . " characters", Z_ERROR_INVALID_INPUT);
-		}
-		
-		switch ($setting) {
-		// Object settings
-		case 'feeds':
-			if (!is_object($value)) {
-				throw new Exception("'value' must be an object", Z_ERROR_INVALID_INPUT);
-			}
-			break;
-		
-		// Array settings
-		case 'tagColors':
-			if (!is_array($value)) {
-				throw new Exception("'value' must be an array", Z_ERROR_INVALID_INPUT);
-			}
-			
-			if (empty($value)) {
-				throw new Exception("'value' array cannot be empty", Z_ERROR_INVALID_INPUT);
-			}
-			break;
-		
-		// String settings
-		default:
-			if (!is_string($value)) {
-				throw new Exception("'value' be a string", Z_ERROR_INVALID_INPUT);
-			}
-			
-			if ($val === "") {
-				throw new Exception("'value' cannot be empty", Z_ERROR_INVALID_INPUT);
-			}
-			break;
-		}
-	}
-	
-	
-	protected static function validateMultiObjectJSON($json, $requestParams) {
-		if (!is_object($json)) {
-			throw new Exception('$json must be a decoded JSON object');
-		}
-		
-		if (sizeOf(get_object_vars($json)) > Zotero_API::$maxWriteSettings) {
-			throw new Exception("Cannot add more than "
-				. Zotero_API::$maxWriteSettings
-				. " settings at a time", Z_ERROR_UPLOAD_TOO_LARGE);
-		}
-	}
-	
-	
-	private static function invalidValueError($prop, $value) {
-		throw new Exception("Invalid '$prop' value '$value'");
-	}
-}
diff --git a/model/StorageFileInfo.inc.php b/model/StorageFileInfo.inc.php
deleted file mode 100644
index 9d515a23..00000000
--- a/model/StorageFileInfo.inc.php
+++ /dev/null
@@ -1,16 +0,0 @@
-<?
-class Zotero_StorageFileInfo {
-	public $hash;
-	public $filename;
-	public $mtime;
-	public $size;
-	public $contentType;
-	public $charset;
-	public $zip = false;
-	public $itemHash;
-	public $itemFilename;
-	
-	public function toJSON() {
-		return json_encode(get_object_vars($this));
-	}
-}
diff --git a/model/Sync.inc.php b/model/Sync.inc.php
deleted file mode 100644
index 91a9dc45..00000000
--- a/model/Sync.inc.php
+++ /dev/null
@@ -1,2288 +0,0 @@
-<?
-/*
-    ***** 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_Sync {
-	public static $defaultAPIVersion = 8;
-	
-	public static $validationError = '';
-	
-	// Don't bother error-checking uploads below this size
-	private static $minErrorCheckSize = 5000;
-	// Don't process uploads larger than this that haven't been error-checked
-	private static $minErrorCheckRequiredSize = 30000;
-	// Don't process uploads larger than this in smallestFirst mode
-	private static $maxSmallestSize = 200000;
-	
-	// This needs to be incremented any time there's a change to the sync response
-	private static $cacheVersion = 1;
-	
-	private static $queueDataThreshold = 65535;
-	
-	public static function getResponseXML($version=null) {
-		if (!$version) {
-			$version = self::$defaultAPIVersion;
-		}
-		
-		$xml = new SimpleXMLElement('<response/>');
-		$xml['version'] = $version;
-		
-		// Generate a fixed timestamp for the response
-		//
-		// Responses that modify data need to override this with the DB transaction
-		// timestamp if the client will use the returned timestamp for comparison purposes
-		$xml['timestamp'] = time();
-		return $xml;
-	}
-	
-	
-	/**
-	 * Check if any of a user's libraries are queued for writing
-	 *
-	 * Clients can still read (/updated) but cannot write (/upload) if this is true
-	 */
-	public static function userIsReadLocked($userID) {
-		Zotero_DB::beginTransaction();
-		
-		$libraryIDs = Zotero_Libraries::getUserLibraries($userID);
-		$sql = "SELECT COUNT(*) FROM syncUploadQueueLocks WHERE libraryID IN (";
-		$sql .= implode(', ', array_fill(0, sizeOf($libraryIDs), '?'));
-		$sql .= ")";
-		
-		$locked = !!Zotero_DB::valueQuery($sql, $libraryIDs);
-		
-		Zotero_DB::commit();
-		
-		return $locked;
-	}
-	
-	
-	/**
-	 * Check if any of a user's libraries are being written to
-	 *
-	 * Clients can't read (/updated) or write (/upload) if this is true
-	 */
-	public static function userIsWriteLocked($userID) {
-		Zotero_DB::beginTransaction();
-		
-		$libraryIDs = Zotero_Libraries::getUserLibraries($userID);
-		
-		$sql = "SELECT COUNT(*) FROM syncProcessLocks WHERE libraryID IN (";
-		$sql .= implode(', ', array_fill(0, sizeOf($libraryIDs), '?'));
-		$sql .= ")";
-		
-		$locked = !!Zotero_DB::valueQuery($sql, $libraryIDs);
-		
-		Zotero_DB::commit();
-		
-		return $locked;
-	}
-	
-	
-	/**
-	 * Get all the libraryIDs referenced in upload XML
-	 */
-	public static function parseAffectedLibraries($xmlstr) {
-		preg_match_all('/<[^>]+ libraryID="([0-9]+)"/', $xmlstr, $matches);
-		$unique = array_values(array_unique($matches[1]));
-		array_walk($unique, function (&$a) {
-			$a = (int) $a;
-		});
-		return $unique;
-	}
-	
-	
-	public static function queueDownload($userID, $sessionID, $lastsync, $version, $updatedObjects, $params=array()) {
-		$syncQueueID = Zotero_ID::getBigInt();
-		
-		// If there's a completed process from this session, delete it, since it
-		// seems the results aren't going to be picked up
-		$sql = "DELETE FROM syncDownloadQueue WHERE sessionID=? AND finished IS NOT NULL";
-		Zotero_DB::query($sql, $sessionID);
-		
-		$sql = "INSERT INTO syncDownloadQueue
-				(syncDownloadQueueID, processorHost, userID, sessionID, lastsync, version, params, objects)
-				VALUES (?, INET_ATON(?), ?, ?, FROM_UNIXTIME(?), ?, ?, ?)";
-		Zotero_DB::query(
-			$sql,
-			array(
-				$syncQueueID,
-				gethostbyname(gethostname()),
-				$userID,
-				$sessionID,
-				$lastsync,
-				$version,
-				json_encode($params),
-				$updatedObjects
-			)
-		);
-		
-		return $syncQueueID;
-	}
-	
-	
-	public static function queueUpload($userID, $sessionID, $xmldata, $affectedLibraries) {
-		$syncQueueID = Zotero_ID::getBigInt();
-		$length = strlen($xmldata);
-		
-		// If there's a completed process from this session, delete it, since it
-		// seems the results aren't going to be picked up
-		$sql = "DELETE FROM syncUploadQueue WHERE sessionID=? AND finished IS NOT NULL";
-		Zotero_DB::query($sql, $sessionID);
-		
-		// Strip control characters in XML data
-		$xmldata = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $xmldata);
-		
-		// If too big for DB, store in S3 and store pointer instead
-		$xmldata = self::storeQueueData('upload', $xmldata);
-		
-		Zotero_DB::beginTransaction();
-		
-		$sql = "INSERT INTO syncUploadQueue
-				(syncUploadQueueID, processorHost, userID, sessionID, xmldata, dataLength, hasCreator)
-				VALUES (?, INET_ATON(?), ?, ?, ?, ?, ?)";
-		Zotero_DB::query(
-			$sql,
-			array(
-				$syncQueueID,
-				gethostbyname(gethostname()),
-				$userID,
-				$sessionID,
-				$xmldata,
-				$length,
-				strpos($xmldata, '<creator') === false ? 0 : 1
-			)
-		);
-		
-		$sql = "INSERT INTO syncUploadQueueLocks VALUES ";
-		$sql .= implode(', ', array_fill(0, sizeOf($affectedLibraries), '(?,?)'));
-		$params = array();
-		foreach ($affectedLibraries as $libraryID) {
-			$params[] = $syncQueueID;
-			$params[] = $libraryID;
-		}
-		try {
-			Zotero_DB::query($sql, $params);
-		}
-		catch (Exception $e) {
-			$msg = $e->getMessage();
-			if (strpos($msg, "Cannot add or update a child row: a foreign key constraint fails") !== false) {
-				foreach ($affectedLibraries as $libraryID) {
-					if (!Zotero_DB::valueQuery("SELECT COUNT(*) FROM libraries WHERE libraryID=?", $libraryID)) {
-						throw new Exception("Library $libraryID does not exist", Z_ERROR_LIBRARY_ACCESS_DENIED);
-					}
-				}
-			}
-			throw ($e);
-		}
-		
-		Zotero_DB::commit();
-		
-		return $syncQueueID;
-	}
-	
-	
-	public static function processDownload($userID, $lastsync, DOMDocument $doc, $params=[]) {
-		self::processDownloadInternal($userID, $lastsync, $doc, null, null, $params);
-	}
-	
-	
-	public static function processUpload($userID, SimpleXMLElement $xml) {
-		return self::processUploadInternal($userID, $xml);
-	}
-	
-	
-	public static function processDownloadFromQueue($syncProcessID) {
-		Zotero_DB::beginTransaction();
-		
-		// Get a queued process
-		$smallestFirst = Z_CONFIG::$SYNC_DOWNLOAD_SMALLEST_FIRST;
-		$sql = "SELECT syncDownloadQueueID, SDQ.userID,
-				UNIX_TIMESTAMP(lastsync) AS lastsync, version, params, added, objects, ipAddress
-				FROM syncDownloadQueue SDQ JOIN sessions USING (sessionID)
-				WHERE started IS NULL ORDER BY tries > 4, ";
-		if ($smallestFirst) {
-			$sql .= "ROUND(objects / 100), ";
-		}
-		$sql .= "added LIMIT 1 FOR UPDATE";
-		$row = Zotero_DB::rowQuery($sql);
-		
-		// No pending processes
-		if (!$row) {
-			Zotero_DB::commit();
-			return 0;
-		}
-		
-		$host = gethostbyname(gethostname());
-		$startedTimestamp = microtime(true);
-		
-		$sql = "UPDATE syncDownloadQueue SET started=FROM_UNIXTIME(?), processorHost=INET_ATON(?) WHERE syncDownloadQueueID=?";
-		Zotero_DB::query($sql, array(round($startedTimestamp), $host, $row['syncDownloadQueueID']));
-		
-		Zotero_DB::commit();
-		
-		$error = false;
-		$lockError = false;
-		
-		try {
-			if (Zotero_Sync::userIsWriteLocked($row['userID'])) {
-				$lockError = true;
-				throw new Exception("User is write locked");
-			}
-			
-			$xml = self::getResponseXML($row['version']);
-			$doc = new DOMDocument();
-			$domResponse = dom_import_simplexml($xml);
-			$domResponse = $doc->importNode($domResponse, true);
-			$doc->appendChild($domResponse);
-			
-			$params = !empty($row['params']) ? json_decode($row['params'], true) : [];
-			
-			self::processDownloadInternal($row['userID'], $row['lastsync'], $doc, $row['syncDownloadQueueID'], $syncProcessID, $params);
-		}
-		catch (Exception $e) {
-			$error = true;
-			$code = $e->getCode();
-			$msg = $e->getMessage();
-		}
-		
-		Zotero_DB::beginTransaction();
-		
-		// Mark download as finished — NULL indicates success
-		if (!$error) {
-			$timestamp = $doc->documentElement->getAttribute('timestamp');
-			
-			$xmldata = $doc->saveXML();
-			$size = strlen($xmldata);
-			
-			// If too big for DB, store in S3 and store pointer instead
-			$xmldata = self::storeQueueData('download', $xmldata);
-			
-			$sql = "UPDATE syncDownloadQueue SET finished=FROM_UNIXTIME(?), xmldata=? WHERE syncDownloadQueueID=?";
-			Zotero_DB::query(
-				$sql,
-				array(
-					$timestamp,
-					$xmldata,
-					$row['syncDownloadQueueID']
-				)
-			);
-			
-			StatsD::increment("sync.process.download.queued.success");
-			StatsD::updateStats("sync.process.download.queued.size", $size);
-			StatsD::timing("sync.process.download.process", round((microtime(true) - $startedTimestamp) * 1000));
-			StatsD::timing("sync.process.download.total", max(0, time() - strtotime($row['added'])) * 1000);
-			
-			self::logDownload(
-				$row['userID'],
-				round($row['lastsync']),
-				$row['objects'],
-				$row['ipAddress'],
-				$host,
-				round((float) microtime(true) - $startedTimestamp, 2),
-				max(0, min(time() - strtotime($row['added']), 65535)),
-				0
-			);
-		}
-		// Timeout/connection error
-		else if (
-				$lockError
-				|| strpos($msg, "Lock wait timeout exceeded; try restarting transaction") !== false
-				|| strpos($msg, "Deadlock found when trying to get lock; try restarting transaction") !== false
-				|| strpos($msg, "Too many connections") !== false
-				|| strpos($msg, "Can't connect to MySQL server") !==false
-				|| $code == Z_ERROR_SHARD_UNAVAILABLE
-		) {
-			Z_Core::logError($e);
-			$sql = "UPDATE syncDownloadQueue SET started=NULL, tries=tries+1 WHERE syncDownloadQueueID=?";
-			Zotero_DB::query($sql, $row['syncDownloadQueueID']);
-			$lockError = true;
-			StatsD::increment("sync.process.download.queued.errorTemporary");
-		}
-		// Save error
-		else {
-			Z_Core::logError($e);
-			$sql = "UPDATE syncDownloadQueue SET finished=?, errorCode=?,
-						errorMessage=? WHERE syncDownloadQueueID=?";
-			Zotero_DB::query(
-				$sql,
-				array(
-					Zotero_DB::getTransactionTimestamp(),
-					$e->getCode(),
-					substr(serialize($e), 0, 65535),
-					$row['syncDownloadQueueID']
-				)
-			);
-			
-			StatsD::increment("sync.process.download.queued.errorPermanent");
-			
-			self::logDownload(
-				$row['userID'],
-				$row['lastsync'],
-				$row['objects'],
-				$row['ipAddress'],
-				$host,
-				round((float) microtime(true) - $startedTimestamp, 2),
-				max(0, min(time() - strtotime($row['added']), 65535)),
-				1
-			);
-		}
-		
-		Zotero_DB::commit();
-		
-		if ($lockError) {
-			return -1;
-		}
-		else if ($error) {
-			return -2;
-		}
-		
-		return 1;
-	}
-	
-	
-	public static function processUploadFromQueue($syncProcessID) {
-		if (Z_Core::probability(30)) {
-			$sql = "DELETE FROM syncProcesses WHERE started < (NOW() - INTERVAL 180 MINUTE)";
-			Zotero_DB::query($sql);
-		}
-		
-		if (Z_Core::probability(30)) {
-			$sql = "UPDATE syncUploadQueue SET started=NULL WHERE started IS NOT NULL AND errorCheck!=1 AND
-						started < (NOW() - INTERVAL 12 MINUTE) AND finished IS NULL AND dataLength<250000";
-			Zotero_DB::query($sql);
-		}
-		
-		if (Z_Core::probability(30)) {
-			$sql = "UPDATE syncUploadQueue SET tries=0 WHERE started IS NULL AND
-					tries>=5 AND finished IS NULL";
-			Zotero_DB::query($sql);
-		}
-		
-		Zotero_DB::beginTransaction();
-		
-		// Get a queued process
-		$smallestFirst = Z_CONFIG::$SYNC_UPLOAD_SMALLEST_FIRST;
-		$sortByQuota = !empty(Z_CONFIG::$SYNC_UPLOAD_SORT_BY_QUOTA);
-		
-		$sql = "SELECT syncUploadQueue.* FROM syncUploadQueue ";
-		if ($sortByQuota) {
-			$sql .= "LEFT JOIN storageAccounts USING (userID) ";
-		}
-		$sql .= "WHERE started IS NULL ";
-		if (self::$minErrorCheckRequiredSize) {
-			$sql .= "AND (errorCheck=2 OR dataLength<" . self::$minErrorCheckRequiredSize . ") ";
-		}
-		if ($smallestFirst && self::$maxSmallestSize) {
-			$sql .= "AND dataLength<" . self::$maxSmallestSize . " ";
-		}
-		$sql .= "ORDER BY tries > 4, ";
-		if ($sortByQuota) {
-			$sql .= "quota DESC, ";
-		}
-		if ($smallestFirst) {
-			//$sql .= "ROUND(dataLength / 1024 / 10), ";
-			$sql .= "dataLength, ";
-		}
-		$sql .= "added LIMIT 1 FOR UPDATE";
-		$row = Zotero_DB::rowQuery($sql);
-		
-		// No pending processes
-		if (!$row) {
-			Zotero_DB::commit();
-			return 0;
-		}
-		
-		$host = gethostbyname(gethostname());
-		
-		$startedTimestamp = microtime(true);
-		list($started, $startedMS) = self::getTimestampParts($startedTimestamp);
-		$sql = "UPDATE syncUploadQueue SET started=FROM_UNIXTIME(?), processorHost=INET_ATON(?) WHERE syncUploadQueueID=?";
-		Zotero_DB::query($sql, array($started, $host, $row['syncUploadQueueID']));
-		
-		Zotero_DB::commit();
-		Zotero_DB::close();
-		
-		$processData = array(
-			"syncUploadQueueID" => $row['syncUploadQueueID'],
-			"userID" => $row['userID'],
-			"dataLength" => $row['dataLength']
-		);
-		Z_Core::$MC->set("syncUploadProcess_" . $syncProcessID, $processData, 86400);
-		
-		$error = false;
-		$lockError = false;
-		try {
-			$queueDataID = self::getQueueDataIDFromField($row['xmldata']);
-			if ($queueDataID) {
-				$row['xmldata'] = self::getQueueData('upload', $queueDataID);
-			}
-			$xml = new SimpleXMLElement($row['xmldata'], LIBXML_COMPACT | LIBXML_PARSEHUGE);
-			$timestamp = self::processUploadInternal($row['userID'], $xml, $row['syncUploadQueueID'], $syncProcessID);
-		}
-		catch (Exception $e) {
-			$error = true;
-			$code = $e->getCode();
-			$msg = $e->getMessage();
-		}
-		
-		Zotero_DB::beginTransaction();
-		
-		// Mark upload as finished — NULL indicates success
-		if (!$error) {
-			$sql = "UPDATE syncUploadQueue SET finished=FROM_UNIXTIME(?) WHERE syncUploadQueueID=?";
-			Zotero_DB::query(
-				$sql,
-				array(
-					$timestamp,
-					$row['syncUploadQueueID']
-				)
-			);
-			
-			StatsD::increment("sync.process.upload.success");
-			StatsD::updateStats("sync.process.upload.size", $row['dataLength']);
-			StatsD::timing("sync.process.upload.process", round((microtime(true) - $startedTimestamp) * 1000));
-			StatsD::timing("sync.process.upload.total", max(0, time() - strtotime($row['added'])) * 1000);
-			
-			try {
-				$sql = "INSERT INTO syncUploadProcessLog
-						(userID, dataLength, processorHost, processDuration, totalDuration, error)
-						VALUES (?,?,INET_ATON(?),?,?,?)";
-				Zotero_DB::query(
-					$sql,
-					array(
-						$row['userID'],
-						$row['dataLength'],
-						$host,
-						round((float) microtime(true) - $startedTimestamp, 2),
-						max(0, min(time() - strtotime($row['added']), 65535)),
-						0
-					)
-				);
-			}
-			catch (Exception $e) {
-				Z_Core::logError($e);
-			}
-			
-			try {
-				self::processPostWriteLog($row['syncUploadQueueID'], $row['userID'], $timestamp);
-			}
-			catch (Exception $e) {
-				Z_Core::logError($e);
-			}
-			
-			// Delete stored data if necessary
-			if ($queueDataID) {
-				self::removeQueueData('upload', $queueDataID);
-			}
-		}
-		// Timeout/connection error
-		else if (
-			strpos($msg, "Lock wait timeout exceeded; try restarting transaction") !== false
-				|| strpos($msg, "Deadlock found when trying to get lock; try restarting transaction") !== false
-				|| strpos($msg, "Too many connections") !== false
-				|| strpos($msg, "Can't connect to MySQL server") !==false
-				|| strpos($msg, "Connection refused") !==false
-				|| strpos($msg, "Connection timed out") !==false
-				|| $code == Z_ERROR_LIBRARY_TIMESTAMP_ALREADY_USED
-				|| $code == Z_ERROR_SHARD_READ_ONLY
-				|| $code == Z_ERROR_SHARD_UNAVAILABLE) {
-			Z_Core::logError($e);
-			$sql = "UPDATE syncUploadQueue SET started=NULL, tries=tries+1 WHERE syncUploadQueueID=?";
-			Zotero_DB::query($sql, $row['syncUploadQueueID']);
-			$lockError = true;
-			StatsD::increment("sync.process.upload.errorTemporary");
-		}
-		// Save error
-		else {
-			// As of PHP 5.3.2 we can't serialize objects containing SimpleXMLElements,
-			// and since the stack trace includes one, we have to catch this and
-			// manually reconstruct an exception
-			$serialized = serialize(
-				new Exception(
-					// Strip invalid \xa0 (due to note sanitizing and  ?)
-					iconv("utf-8", "utf-8//IGNORE", $msg),
-					$e->getCode()
-				)
-			);
-			
-			Z_Core::logError($e);
-			$sql = "UPDATE syncUploadQueue SET finished=?, errorCode=?, errorMessage=? WHERE syncUploadQueueID=?";
-			Zotero_DB::query(
-				$sql,
-				array(
-					Zotero_DB::getTransactionTimestamp(),
-					$e->getCode(),
-					$serialized,
-					$row['syncUploadQueueID']
-				)
-			);
-			
-			StatsD::increment("sync.process.upload.errorPermanent");
-			
-			try {
-				$sql = "INSERT INTO syncUploadProcessLog
-						(userID, dataLength, processorHost, processDuration, totalDuration, error)
-						VALUES (?,?,INET_ATON(?),?,?,?)";
-				Zotero_DB::query(
-					$sql,
-					array(
-						$row['userID'],
-						$row['dataLength'],
-						$host,
-						round((float) microtime(true) - $startedTimestamp, 2),
-						max(0, min(time() - strtotime($row['added']), 65535)),
-						1
-					)
-				);
-			}
-			catch (Exception $e) {
-				Z_Core::logError($e);
-			}
-		}
-		
-		// Clear read locks
-		$sql = "DELETE FROM syncUploadQueueLocks WHERE syncUploadQueueID=?";
-		Zotero_DB::query($sql, $row['syncUploadQueueID']);
-		
-		Zotero_DB::commit();
-		
-		if ($lockError) {
-			return -1;
-		}
-		else if ($error) {
-			return -2;
-		}
-		
-		return 1;
-	}
-	
-	
-	public static function checkUploadForErrors($syncProcessID) {
-		Zotero_DB::beginTransaction();
-		
-		if (Z_Core::probability(30)) {
-			$sql = "UPDATE syncUploadQueue SET started=NULL, errorCheck=0 WHERE started IS NOT NULL AND errorCheck=1 AND
-						started < (NOW() - INTERVAL 15 MINUTE) AND finished IS NULL";
-			Zotero_DB::query($sql);
-		}
-		
-		// Get a queued process that hasn't been error-checked and is large enough to warrant it
-		$sql = "SELECT * FROM syncUploadQueue WHERE started IS NULL AND errorCheck=0
-				AND dataLength>=" . self::$minErrorCheckSize . " ORDER BY added LIMIT 1 FOR UPDATE";
-		$row = Zotero_DB::rowQuery($sql);
-		
-		// No pending processes
-		if (!$row) {
-			Zotero_DB::commit();
-			return 0;
-		}
-		
-		$sql = "UPDATE syncUploadQueue SET started=NOW(), errorCheck=1 WHERE syncUploadQueueID=?";
-		Zotero_DB::query($sql, array($row['syncUploadQueueID']));
-		
-		// We track error processes as upload processes that just get reset back to
-		// started=NULL on completion (but with errorCheck=2)
-		self::addUploadProcess($row['userID'], null, $row['syncUploadQueueID'], $syncProcessID);
-		
-		Zotero_DB::commit();
-		
-		try {
-			$doc = new DOMDocument();
-			$doc->loadXML($row['xmldata'], LIBXML_COMPACT | LIBXML_PARSEHUGE);
-			
-			// Get long tags
-			$value = Zotero_Tags::getLongDataValueFromXML($doc);
-			if ($value) {
-				throw new Exception("Tag '" . $value . "' too long", Z_ERROR_TAG_TOO_LONG);
-			}
-			
-			// Get long collection names
-			$value = Zotero_Collections::getLongDataValueFromXML($doc);
-			if ($value) {
-				throw new Exception("Collection '" . $value . "' too long", Z_ERROR_COLLECTION_TOO_LONG);
-			}
-			
-			// Get long creator names
-			$node = Zotero_Creators::getLongDataValueFromXML($doc); // returns DOMNode rather than value
-			if ($node) {
-				$name = mb_substr($node->nodeValue, 0, 50);
-				throw new Exception("=The name ‘{$name}…’ is too long to sync.\n\n"
-					. "Search for the item with this name and shorten it. "
-					. "Note that the item may be in the trash or in a group library.\n\n"
-					. "If you receive this message repeatedly for items saved from a "
-					. "particular site, you can report this issue in the Zotero Forums.",
-					Z_ERROR_CREATOR_TOO_LONG);
-			}
-			
-			// Get long item data fields
-			$node = Zotero_Items::getLongDataValueFromXML($doc); // returns DOMNode rather than value
-			if ($node) {
-				$libraryID = $node->parentNode->getAttribute('libraryID');
-				$key = $node->parentNode->getAttribute('key');
-				if ($libraryID) {
-					$key = $libraryID . "/" . $key;
-				}
-				$fieldName = $node->getAttribute('name');
-				$fieldName = Zotero_ItemFields::getLocalizedString(null, $fieldName);
-				if ($fieldName) {
-					$start = "$fieldName field";
-				}
-				else {
-					$start = "Field";
-				}
-				throw new Exception(
-					"=$start value '" . mb_substr($node->nodeValue, 0, 75)
-					. "...' too long for item '$key'", Z_ERROR_FIELD_TOO_LONG
-				);
-			}
-		}
-		catch (Exception $e) {
-			//Z_Core::logError($e);
-			
-			Zotero_DB::beginTransaction();
-			
-			$sql = "UPDATE syncUploadQueue SET syncProcessID=NULL, finished=?,
-						errorCode=?, errorMessage=? WHERE syncUploadQueueID=?";
-			Zotero_DB::query(
-				$sql,
-				array(
-					Zotero_DB::getTransactionTimestamp(),
-					$e->getCode(),
-					serialize($e),
-					$row['syncUploadQueueID']
-				)
-			);
-			
-			$sql = "DELETE FROM syncUploadQueueLocks WHERE syncUploadQueueID=?";
-			Zotero_DB::query($sql, $row['syncUploadQueueID']);
-			
-			self::removeUploadProcess($syncProcessID);
-			
-			Zotero_DB::commit();
-			
-			return -2;
-		}
-		
-		Zotero_DB::beginTransaction();
-		
-		$sql = "UPDATE syncUploadQueue SET syncProcessID=NULL, started=NULL, errorCheck=2 WHERE syncUploadQueueID=?";
-		Zotero_DB::query($sql, $row['syncUploadQueueID']);
-		
-		self::removeUploadProcess($syncProcessID);
-		
-		Zotero_DB::commit();
-		
-		return 1;
-	}
-	
-	
-	public static function getUploadQueueIDByUserID($userID) {
-		$sql = "SELECT syncUploadQueueID FROM syncUploadQueue WHERE userID=?";
-		return Zotero_DB::valueQuery($sql, $userID);
-	}
-	
-	
-	public static function postWriteLog($syncUploadQueueID, $objectType, $id, $action) {
-		$sql = "INSERT IGNORE INTO syncUploadQueuePostWriteLog VALUES (?,?,?,?)";
-		Zotero_DB::query($sql, array($syncUploadQueueID, $objectType, $id, $action));
-	}
-	
-	
-	public static function processPostWriteLog($syncUploadQueueID, $userID, $timestamp) {
-		// Increase timestamp by a second past the time of the queued process
-		$timestamp++;
-		
-		$sql = "SELECT * FROM syncUploadQueuePostWriteLog WHERE syncUploadQueueID=?";
-		$entries = Zotero_DB::query($sql, $syncUploadQueueID);
-		foreach ($entries as $entry) {
-			switch ($entry['objectType']) {
-				case 'group':
-					switch ($entry['action']) {
-						case 'update':
-							$sql = "UPDATE groups SET dateModified=FROM_UNIXTIME(?) WHERE groupID=?";
-							$affected = Zotero_DB::query($sql, array($timestamp, $entry['ids']));
-							break;
-						
-						case 'delete':
-							$sql = "UPDATE syncDeleteLogIDs SET timestamp=FROM_UNIXTIME(?)
-									WHERE libraryID=? AND objectType='group' AND id=?";
-							$userLibraryID = Zotero_Users::getLibraryIDFromUserID($userID);
-							$affected = Zotero_DB::query(
-								$sql,
-								array($timestamp, $userLibraryID, $entry['ids']),
-								Zotero_Shards::getByLibraryID($userLibraryID)
-							);
-							break;
-						
-						default:
-							throw new Exception("Unsupported action {$entry['action']} for type {$entry['objectType']}");
-					}
-					break;
-				
-				case 'groupUser':
-					// If the affected user isn't the queued user, this isn't necessary
-					list ($groupID, $groupUserID) = explode('-', $entry['ids']);
-					if ($userID != $groupUserID) {
-						throw new Exception("Upload user is not logged user");
-					}
-					
-					switch ($entry['action']) {
-						case 'update':
-							$sql = "UPDATE groupUsers SET lastUpdated=FROM_UNIXTIME(?) WHERE groupID=? AND userID=?";
-							$affected = Zotero_DB::query($sql, array($timestamp, $groupID, $userID));
-							break;
-						
-						case 'delete':
-							$userLibraryID = Zotero_Users::getLibraryIDFromUserID($userID);
-							$sql = "UPDATE syncDeleteLogIDs SET timestamp=FROM_UNIXTIME(?)
-									WHERE libraryID=? AND objectType='group' AND id=?";
-							$affected = Zotero_DB::query(
-								$sql,
-								array($timestamp, $userLibraryID, $groupID),
-								Zotero_Shards::getByLibraryID($userLibraryID)
-							);
-							break;
-						
-						default:
-							throw new Exception("Unsupported action {$entry['action']} for type {$entry['objectType']}");
-					}
-					break;
-				
-				default:
-					throw new Exception ("Unknown object type {$entry['objectType']}");
-			}
-			
-			if ($affected == 0) {
-				Z_Core::logError(
-					"Post-queue write "
-						. "{$entry['syncUploadQueueID']}/"
-						. "{$entry['objectType']}/"
-						. "{$entry['ids']}/"
-						. "{$entry['action']}"
-						. " didn't change any rows"
-				);
-			}
-		}
-	}
-	
-	
-	public static function countQueuedDownloadProcesses() {
-		$sql = "SELECT COUNT(*) FROM syncDownloadQueue WHERE started IS NULL";
-		return Zotero_DB::valueQuery($sql);
-	}
-	
-	
-	public static function countQueuedUploadProcesses($errorCheck=false) {
-		$sql = "SELECT COUNT(*) FROM syncUploadQueue WHERE started IS NULL";
-		// errorCheck=0 indicates that the upload has not been checked for errors
-		if ($errorCheck) {
-			$sql .= " AND errorCheck=0 AND dataLength>5000";
-		}
-		return Zotero_DB::valueQuery($sql);
-	}
-	
-	
-	public static function getOldDownloadProcesses($host=null, $seconds=60) {
-		$sql = "SELECT syncDownloadProcessID FROM syncDownloadQueue
-				WHERE started < NOW() - INTERVAL ? SECOND";
-		$params = array($seconds);
-		if ($host) {
-			$sql .= " AND processorHost=INET_ATON(?)";
-			$params[] = $host;
-		}
-		return Zotero_DB::columnQuery($sql, $params);
-	}
-	
-	
-	public static function getOldUploadProcesses($host, $seconds=60) {
-		$sql = "SELECT syncProcessID FROM syncUploadQueue
-				WHERE started < NOW() - INTERVAL ? SECOND AND errorCheck!=1";
-		$params = array($seconds);
-		if ($host) {
-			$sql .= " AND processorHost=INET_ATON(?)";
-			$params[] = $host;
-		}
-		return Zotero_DB::columnQuery($sql, $params);
-	}
-	
-	
-	public static function getOldErrorProcesses($host, $seconds=60) {
-		$sql = "SELECT syncProcessID FROM syncUploadQueue
-				WHERE started < NOW() - INTERVAL ? SECOND AND errorCheck=1";
-		$params = array($seconds);
-		if ($host) {
-			$sql .= " AND processorHost=INET_ATON(?)";
-			$params[] = $host;
-		}
-		return Zotero_DB::columnQuery($sql, $params);
-	}
-	
-	
-	/**
-	 * Remove process id from process in database
-	 */
-	public static function removeDownloadProcess($syncDownloadProcessID) {
-		$sql = "UPDATE syncDownloadQueue SET syncDownloadProcessID=NULL
-				WHERE syncDownloadProcessID=?";
-		Zotero_DB::query($sql, $syncDownloadProcessID);
-	}
-	
-	
-	/**
-	 * Remove upload process and locks from database
-	 */
-	public static function removeUploadProcess($syncProcessID) {
-		$sql = "DELETE FROM syncProcesses WHERE syncProcessID=?";
-		Zotero_DB::query($sql, $syncProcessID);
-	}
-	
-	
-	/**
-	 * Purge error process from database and reset errorCheck to 0
-	 *
-	 * This is called only after an error check is orphaned
-	 */
-	public static function purgeErrorProcess($syncErrorProcessID) {
-		Zotero_DB::beginTransaction();
-		
-		self::removeUploadProcess($syncErrorProcessID);
-		
-		$sql = "UPDATE syncUploadQueue SET errorCheck=0 WHERE syncProcessID=?";
-		Zotero_DB::query($sql, $syncErrorProcessID);
-		
-		Zotero_DB::commit();
-	}
-	
-	
-	public static function getCachedDownload($userID, $lastsync, $apiVersion, $cacheKeyExtra="") {
-		if (!$lastsync) {
-			throw new Exception('$lastsync not provided');
-		}
-		
-		$s3Client = Z_Core::$AWS->createS3();
-		
-		$s3Key = $apiVersion . "/" . md5(
-			Zotero_Users::getUpdateKey($userID)
-			. "_" . $lastsync
-			// Remove after 2.1 sync cutoff
-			. ($apiVersion >= 9 ? "_" . $apiVersion : "")
-			. "_" . self::$cacheVersion
-			. (!empty($cacheKeyExtra) ? "_" . $cacheKeyExtra : "")
-		);
-		
-		// Check S3 for file
-		try {
-			try {
-				$result = $s3Client->getObject([
-					'Bucket' => Z_CONFIG::$S3_BUCKET_CACHE,
-					'Key' => $s3Key
-				]);
-				$xmldata = (string) $result['Body'];
-			}
-			catch (Aws\S3\Exception\S3Exception $e) {
-				if ($e->getAwsErrorCode() == 'NoSuchKey') {
-					$xmldata = false;
-				}
-				else {
-					throw $e;
-				}
-			}
-		}
-		catch (Exception $e) {
-			Z_Core::logError("Warning: '" . $e . "' getting cached download from S3");
-			$xmldata = false;
-		}
-		
-		// Update the last-used timestamp in S3
-		if ($xmldata) {
-			try {
-				$s3Client->copyObject([
-					'Bucket' => Z_CONFIG::$S3_BUCKET_CACHE,
-					'Key' => $s3Key,
-					'CopySource' => Z_CONFIG::$S3_BUCKET_CACHE . "/" . $s3Key,
-					'Metadata' => [
-						'last-used' => time()
-					],
-					'MetadataDirective' => 'REPLACE'
-				]);
-			}
-			catch (Exception $e) {
-				error_log("WARNING: " . $e);
-			}
-		}
-		
-		return $xmldata;
-	}
-	
-	
-	public static function cacheDownload($userID, $updateKey, $lastsync, $apiVersion, $xmldata, $cacheKeyExtra="") {
-		$s3Client = Z_Core::$AWS->createS3();
-		
-		$s3Key = $apiVersion . "/" . md5(
-			$updateKey . "_" . $lastsync
-			// Remove after 2.1 sync cutoff
-			. ($apiVersion >= 9 ? "_" . $apiVersion : "")
-			. "_" . self::$cacheVersion
-			. (!empty($cacheKeyExtra) ? "_" . $cacheKeyExtra : "")
-		);
-		
-		// Add to S3
-		$response = $s3Client->putObject([
-			'Bucket' => Z_CONFIG::$S3_BUCKET_CACHE,
-			'Key' => $s3Key,
-			'Body' => $xmldata
-		]);
-	}
-	
-	
-	public static function getQueueDataIDFromField($field) {
-		if (substr($field, 0, 7) == 'STORED:') {
-			return substr($field, 7);
-		}
-		return false;
-	}
-	
-	
-	public static function getQueueData($type, $queueDataID) {
-		$s3Client = Z_Core::$AWS->createS3();
-		$s3Key = "sync/$type/$queueDataID";
-		
-		$tries = 0;
-		$maxTries = 5;
-		while (true) {
-			try {
-				$result = $s3Client->getObject([
-					'Bucket' => Z_CONFIG::$S3_BUCKET_CACHE,
-					'Key' => $s3Key
-				]);
-				break;
-			}
-			catch (Exception $e) {
-				$tries++;
-				if ($tries >= $maxTries) {
-					throw new Exception($e);
-				}
-				error_log("WARNING: " . $e);
-				sleep(pow(2, $tries));
-				continue;
-			}
-		}
-		
-		return (string) $result['Body'];
-	}
-	
-	
-	public static function storeQueueData($type, $xmldata) {
-		if (strlen($xmldata) < self::$queueDataThreshold) {
-			return $xmldata;
-		}
-		
-		$s3Client = Z_Core::$AWS->createS3();
-		$queueDataID = Zotero_Utilities::randomString(32, 'mixed', true);
-		$s3Key = "sync/$type/$queueDataID";
-		
-		$tries = 0;
-		$maxTries = 5;
-		while (true) {
-			try {
-				$s3Client->putObject([
-					'Bucket' => Z_CONFIG::$S3_BUCKET_CACHE,
-					'Key' => $s3Key,
-					'Body' => $xmldata
-				]);
-			}
-			catch (Exception $e) {
-				$tries++;
-				if ($tries >= $maxTries) {
-					throw new Exception($e);
-				}
-				error_log("WARNING: " . $e);
-				sleep(pow(2, $tries));
-				continue;
-			}
-			break;
-		}
-		
-		return "STORED:" . $queueDataID;
-	}
-	
-	
-	public static function removeQueueData($type, $queueDataID) {
-		$s3Client = Z_Core::$AWS->createS3();
-		$s3Key = "sync/$type/$queueDataID";
-		
-		try {
-			$s3Client->deleteObject([
-				'Bucket' => Z_CONFIG::$S3_BUCKET_CACHE,
-				'Key' => $s3Key
-			]);
-		}
-		catch (Exception $e) {
-			error_log("WARNING: " . $e);
-		}
-	}
-	
-	
-	/**
-	 * Get the result of a queued download process for a given sync session
-	 *
-	 * If still queued, return false
-	 * If success, return "<data ..."
-	 * If error, return array('timestamp' => "123456789.1234", 'exception' => Exception)
-	 * If not queued, return -1
-	 */
-	public static function getSessionDownloadResult($sessionID) {
-		Zotero_DB::beginTransaction();
-		$sql = "SELECT syncDownloadQueueID, finished, xmldata, errorCode, errorMessage "
-			. "FROM syncDownloadQueue WHERE sessionID=?";
-		$row = Zotero_DB::rowQuery($sql, $sessionID);
-		if (!$row) {
-			Zotero_DB::commit();
-			return -1;
-		}
-		
-		if (is_null($row['finished'])) {
-			// Every two minutes, update lastCheck
-			if (!Z_Core::$MC->get("syncDownloadLastCheck_$sessionID")) {
-				$sql = "UPDATE syncDownloadQueue SET lastCheck=NOW() WHERE sessionID=?";
-				Zotero_DB::query($sql, $sessionID);
-				
-				Z_Core::$MC->set("syncDownloadLastCheck_$sessionID", true, 120);
-			}
-			Zotero_DB::commit();
-			return false;
-		}
-		
-		// On success, get download data from S3 or DB
-		if (is_null($row['errorCode'])) {
-			$queueDataID = self::getQueueDataIDFromField($row['xmldata']);
-			
-			// S3
-			if ($queueDataID) {
-				$xmldata = self::getQueueData('download', $queueDataID);
-			}
-			// DB
-			else {
-				$queueDataID = false;
-				$xmldata = $row['xmldata'];
-			}
-		}
-		
-		$sql = "DELETE FROM syncDownloadQueue WHERE sessionID=?";
-		Zotero_DB::query($sql, $sessionID);
-		Zotero_DB::commit();
-		
-		// Success
-		if (is_null($row['errorCode'])) {
-			// Delete stored data if necessary
-			if ($queueDataID) {
-				self::removeQueueData('download', $queueDataID);
-			}
-			return $xmldata;
-		}
-		
-		$e = @unserialize($row['errorMessage']);
-		
-		// In case it's not a valid exception for some reason, make one
-		if (!($e instanceof Exception)) {
-			$e = new Exception($row['errorMessage'], $row['errorCode']);
-		}
-		
-		throw ($e);
-	}
-	
-	
-	/**
-	 * Get the result of a queued process for a given sync session
-	 *
-	 * If no result, return false
-	 * If success, return array('timestamp' => "123456789")
-	 * If error, return array('xmldata' => "<data ...", 'exception' => Exception)
-	 */
-	public static function getSessionUploadResult($sessionID) {
-		Zotero_DB::beginTransaction();
-		$sql = "SELECT UNIX_TIMESTAMP(finished) AS finished, xmldata, errorCode, errorMessage
-				FROM syncUploadQueue WHERE sessionID=?";
-		$row = Zotero_DB::rowQuery($sql, $sessionID);
-		if (!$row) {
-			Zotero_DB::commit();
-			throw new Exception("Queued upload not found for session");
-		}
-		
-		if (is_null($row['finished'])) {
-			Zotero_DB::beginTransaction();
-			return false;
-		}
-		
-		$sql = "DELETE FROM syncUploadQueue WHERE sessionID=?";
-		Zotero_DB::query($sql, $sessionID);
-		Zotero_DB::commit();
-		
-		// Success
-		if (is_null($row['errorCode'])) {
-			return array('timestamp' => $row['finished']);
-		}
-		
-		// On failure, get XML data from cache for error report and clear from cache
-		$queueDataID = self::getQueueDataIDFromField($row['xmldata']);
-		if ($queueDataID) {
-			$row['xmldata'] = self::getQueueData('upload', $queueDataID);
-			self::removeQueueData('upload', $queueDataID);
-		}
-		
-		$e = @unserialize($row['errorMessage']);
-		
-		return array('timestamp' => $row['finished'], 'xmldata' => $row['xmldata'], 'exception' => $e);
-	}
-	
-	
-	public static function logDownload($userID, $lastsync, $object, $ipAddress, $host, $processDuration, $totalDuration, $error) {
-		try {
-			if (is_numeric($ipAddress)) {
-				$ipParam = "?";
-			}
-			else {
-				$ipParam = "INET_ATON(?)";
-			}
-			
-			$sql = "INSERT INTO syncDownloadProcessLog
-					(userID, lastsync, objects, ipAddress, processorHost, processDuration, totalDuration, error)
-					VALUES (?,FROM_UNIXTIME(?),?,$ipParam,INET_ATON(?),?,?,?)";
-			Zotero_DB::query(
-				$sql,
-				array(
-					$userID,
-					$lastsync,
-					$object,
-					$ipAddress,
-					$host,
-					$processDuration,
-					$totalDuration,
-					$error
-				)
-			);
-		}
-		catch (Exception $e) {
-			Z_Core::logError($e);
-		}
-	}
-	
-	
-	//
-	//
-	// Private methods
-	//
-	//
-	private static function processDownloadInternal($userID, $lastsync, DOMDocument $doc, $syncDownloadQueueID=null, $syncDownloadProcessID=null, $params=[]) {
-		$apiVersion = (int) $doc->documentElement->getAttribute('version');
-		
-		if ($lastsync == 1) {
-			StatsD::increment("sync.process.download.full");
-		}
-		
-		// TEMP
-		$cacheKeyExtra = (!empty($params['ft']) ? json_encode($params['ft']) : "")
-			. (!empty($params['ftkeys']) ? json_encode($params['ftkeys']) : "");
-		
-		try {
-			$cached = Zotero_Sync::getCachedDownload($userID, $lastsync, $apiVersion, $cacheKeyExtra);
-			if ($cached) {
-				$doc->loadXML($cached);
-				StatsD::increment("sync.process.download.cache.hit");
-				return;
-			}
-		}
-		catch (Exception $e) {
-			$msg = $e->getMessage();
-			if (strpos($msg, "Too many connections") !== false) {
-				$msg = "'Too many connections' from MySQL";
-			}
-			else {
-				$msg = "'$msg'";
-			}
-			Z_Core::logError("Warning: $msg getting cached download");
-			StatsD::increment("sync.process.download.cache.error");
-		}
-		
-		set_time_limit(1800);
-		
-		$profile = false;
-		if ($profile) {
-			$shardID = Zotero_Shards::getByUserID($userID);
-			Zotero_DB::profileStart(0);
-		}
-		
-		if ($syncDownloadQueueID) {
-			self::addDownloadProcess($syncDownloadQueueID, $syncDownloadProcessID);
-		}
-		
-		$updatedNode = $doc->createElement('updated');
-		$doc->documentElement->appendChild($updatedNode);
-		
-		$userLibraryID = Zotero_Users::getLibraryIDFromUserID($userID);
-		
-		$updatedCreators = array();
-		
-		try {
-			Zotero_DB::beginTransaction();
-			
-			// Blocks until any upload processes are done
-			$updateTimes = Zotero_Libraries::getUserLibraryUpdateTimes($userID);
-			
-			$timestamp = Zotero_DB::getTransactionTimestampUnix();
-			$doc->documentElement->setAttribute('timestamp', $timestamp);
-			
-			$doc->documentElement->setAttribute('userID', $userID);
-			$doc->documentElement->setAttribute('defaultLibraryID', $userLibraryID);
-			$updateKey = Zotero_Users::getUpdateKey($userID);
-			$doc->documentElement->setAttribute('updateKey', $updateKey);
-			
-			// Get libraries with update times >= $timestamp
-			$updatedLibraryIDs = array();
-			foreach ($updateTimes as $libraryID=>$timestamp) {
-				if ($timestamp >= $lastsync) {
-					$updatedLibraryIDs[] = $libraryID;
-				}
-			}
-			
-			// Add new and updated groups
-			$joinedGroups = Zotero_Groups::getJoined($userID, (int) $lastsync);
-			$updatedIDs = array_unique(array_merge(
-				$joinedGroups, Zotero_Groups::getUpdated($userID, (int) $lastsync)
-			));
-			if ($updatedIDs) {
-				$node = $doc->createElement('groups');
-				$showGroups = false;
-				
-				foreach ($updatedIDs as $id) {
-					$group = new Zotero_Group;
-					$group->id = $id;
-					$xmlElement = $group->toXML($userID);
-					
-					$newNode = dom_import_simplexml($xmlElement);
-					$newNode = $doc->importNode($newNode, true);
-					$node->appendChild($newNode);
-					$showGroups = true;
-				}
-				
-				if ($showGroups) {
-					$updatedNode->appendChild($node);
-				}
-			}
-			
-			// If there's updated data in any library or
-			// there are any new groups (in which case we need all their data)
-			$hasData = $updatedLibraryIDs || $joinedGroups;
-			if ($hasData) {
-				foreach (Zotero_DataObjects::$classicObjectTypes as $syncObject) {
-					$Name = $syncObject['singular']; // 'Item'
-					$Names = $syncObject['plural']; // 'Items'
-					$name = strtolower($Name); // 'item'
-					$names = strtolower($Names); // 'items'
-					
-					$className = 'Zotero_' . $Names;
-					
-					$updatedIDsByLibraryID = call_user_func(array($className, 'getUpdated'), $userID, $lastsync, $updatedLibraryIDs);
-					if ($updatedIDsByLibraryID) {
-						$node = $doc->createElement($names);
-						foreach ($updatedIDsByLibraryID as $libraryID=>$ids) {
-							if ($name == 'creator') {
-								$updatedCreators[$libraryID] = $ids;
-							}
-							
-							foreach ($ids as $id) {
-								if ($name == 'item') {
-									$obj = call_user_func(array($className, 'get'), $libraryID, $id);
-									$data = array(
-										'updatedCreators' => isset($updatedCreators[$libraryID]) ? $updatedCreators[$libraryID] : array()
-									);
-									$xmlElement = Zotero_Items::convertItemToXML($obj, $data, $apiVersion);
-								}
-								else {
-									$instanceClass = 'Zotero_' . $Name;
-									$obj = new $instanceClass;
-									if (method_exists($instanceClass, '__construct')) {
-										$obj->__construct();
-									}
-									$obj->libraryID = $libraryID;
-									if ($name == 'setting') {
-										$obj->name = $id;
-									}
-									else {
-										$obj->id = $id;
-									}
-									if ($name == 'tag') {
-										$xmlElement = call_user_func(array($className, "convert{$Name}ToXML"), $obj, true);
-									}
-									else if ($name == 'creator') {
-										$xmlElement = call_user_func(array($className, "convert{$Name}ToXML"), $obj, $doc);
-										if ($xmlElement->getAttribute('libraryID') == $userLibraryID) {
-											$xmlElement->removeAttribute('libraryID');
-										}
-										$node->appendChild($xmlElement);
-									}
-									else if ($name == 'relation') {
-										// Skip new-style related items
-										if ($obj->predicate == 'dc:relation') {
-											continue;
-										}
-										$xmlElement = call_user_func(array($className, "convert{$Name}ToXML"), $obj);
-										if ($apiVersion <= 8) {
-											unset($xmlElement['libraryID']);
-										}
-									}
-									else if ($name == 'setting') {
-										$xmlElement = call_user_func(array($className, "convert{$Name}ToXML"), $obj, $doc);
-										$node->appendChild($xmlElement);
-									}
-									else {
-										$xmlElement = call_user_func(array($className, "convert{$Name}ToXML"), $obj);
-									}
-								}
-								
-								if ($xmlElement instanceof SimpleXMLElement) {
-									if ($xmlElement['libraryID'] == $userLibraryID) {
-										unset($xmlElement['libraryID']);
-									}
-									
-									$newNode = dom_import_simplexml($xmlElement);
-									$newNode = $doc->importNode($newNode, true);
-									$node->appendChild($newNode);
-								}
-							}
-						}
-						if ($node->hasChildNodes()) {
-							$updatedNode->appendChild($node);
-						}
-					}
-				}
-			}
-			
-			// Add full-text content if the client supports it
-			if (isset($params['ft'])) {
-				$libraries = Zotero_Libraries::getUserLibraries($userID);
-				$fulltextNode = false;
-				foreach ($libraries as $libraryID) {
-					if (!empty($params['ftkeys']) && $params['ftkeys'] === 'all') {
-						$ftlastsync = 1;
-					}
-					else {
-						$ftlastsync = $lastsync;
-					}
-					if (!empty($params['ftkeys'][$libraryID])) {
-						$keys = $params['ftkeys'][$libraryID];
-					}
-					else {
-						$keys = [];
-					}
-					$data = Zotero_FullText::getNewerInLibraryByTime($libraryID, $ftlastsync, $keys);
-					if ($data) {
-						if (!$fulltextNode) {
-							$fulltextNode = $doc->createElement('fulltexts');
-						}
-						foreach ($data as $itemData) {
-							if ($params['ft']) {
-								$empty = $itemData['empty'];
-							}
-							// If full-text syncing is disabled, leave content empty
-							else {
-								$empty = true;
-							}
-							$first = false;
-							$node = Zotero_FullText::itemDataToXML($itemData, $doc, $empty);
-							$fulltextNode->appendChild($node);
-						}
-					}
-				}
-				if ($fulltextNode) {
-					$updatedNode->appendChild($fulltextNode);
-				}
-			}
-			
-			// Get earliest timestamp
-			$earliestModTime = Zotero_Users::getEarliestDataTimestamp($userID);
-			$doc->documentElement->setAttribute('earliest', $earliestModTime ? $earliestModTime : 0);
-			
-			// Deleted objects
-			$deletedKeys = $hasData ? self::getDeletedObjectKeys($userID, $lastsync, true) : false;
-			$deletedIDs = self::getDeletedObjectIDs($userID, $lastsync, true);
-			if ($deletedKeys || $deletedIDs) {
-				$deletedNode = $doc->createElement('deleted');
-				
-				// Add deleted data objects
-				if ($deletedKeys) {
-					foreach (Zotero_DataObjects::$classicObjectTypes as $syncObject) {
-						$Name = $syncObject['singular']; // 'Item'
-						$Names = $syncObject['plural']; // 'Items'
-						$name = strtolower($Name); // 'item'
-						$names = strtolower($Names); // 'items'
-						
-						if (empty($deletedKeys[$names])) {
-							continue;
-						}
-						
-						$typeNode = $doc->createElement($names);
-						
-						foreach ($deletedKeys[$names] as $row) {
-							$node = $doc->createElement($name);
-							if ($row['libraryID'] != $userLibraryID || $name == 'setting') {
-								$node->setAttribute('libraryID', $row['libraryID']);
-							}
-							$node->setAttribute('key', $row['key']);
-							$typeNode->appendChild($node);
-						}
-						$deletedNode->appendChild($typeNode);
-					}
-				}
-				
-				// Add deleted groups
-				if ($deletedIDs) {
-					$name = "group";
-					$names = "groups";
-					
-					$typeNode = $doc->createElement($names);
-					$ids = $doc->createTextNode(implode(' ', $deletedIDs[$names]));
-					$typeNode->appendChild($ids);
-					$deletedNode->appendChild($typeNode);
-				}
-				
-				$updatedNode->appendChild($deletedNode);
-			}
-			
-			Zotero_DB::commit();
-		}
-		catch (Exception $e) {
-			Zotero_DB::rollback(true);
-			if ($syncDownloadQueueID) {
-				self::removeDownloadProcess($syncDownloadProcessID);
-			}
-			throw ($e);
-		}
-		
-		function relaxNGErrorHandler($errno, $errstr) {
-			Zotero_Sync::$validationError = $errstr;
-		}
-		set_error_handler('relaxNGErrorHandler');
-		$valid = $doc->relaxNGValidate(Z_ENV_MODEL_PATH . 'relax-ng/updated.rng');
-		restore_error_handler();
-		if (!$valid) {
-			if ($syncDownloadQueueID) {
-				self::removeDownloadProcess($syncDownloadProcessID);
-			}
-			throw new Exception(self::$validationError . "\n\nXML:\n\n" .  $doc->saveXML());
-		}
-		
-		// Cache response if response isn't empty
-		try {
-			if ($doc->documentElement->firstChild->hasChildNodes()) {
-				self::cacheDownload($userID, $updateKey, $lastsync, $apiVersion, $doc->saveXML(), $cacheKeyExtra);
-			}
-		}
-		catch (Exception $e) {
-			Z_Core::logError("WARNING: " . $e);
-		}
-		
-		if ($syncDownloadQueueID) {
-			self::removeDownloadProcess($syncDownloadProcessID);
-		}
-		
-		if ($profile) {
-			$shardID = Zotero_Shards::getByUserID($userID);
-			Zotero_DB::profileEnd(0);
-		}
-	}
-	
-	
-	private static function processUploadInternal($userID, SimpleXMLElement $xml, $syncQueueID=null, $syncProcessID=null) {
-		$userLibraryID = Zotero_Users::getLibraryIDFromUserID($userID);
-		$affectedLibraries = self::parseAffectedLibraries($xml->asXML());
-		// Relations-only uploads don't have affected libraries
-		if (!$affectedLibraries) {
-			$affectedLibraries = array(Zotero_Users::getLibraryIDFromUserID($userID));
-		}
-		$processID = self::addUploadProcess($userID, $affectedLibraries, $syncQueueID, $syncProcessID);
-		
-		set_time_limit(5400);
-		
-		$profile = false;
-		if ($profile) {
-			$shardID = Zotero_Shards::getByUserID($userID);
-			Zotero_DB::profileStart($shardID);
-		}
-		
-		try {
-			Zotero_DB::beginTransaction();
-			
-			// Mark libraries as updated
-			foreach ($affectedLibraries as $libraryID) {
-				Zotero_Libraries::updateVersion($libraryID);
-			}
-			$timestamp = Zotero_Libraries::updateTimestamps($affectedLibraries);
-			Zotero_DB::registerTransactionTimestamp($timestamp);
-			
-			// Make sure no other upload sessions use this same timestamp
-			// for any of these libraries, since we return >= 1 as the next
-			// last sync time
-			if (!Zotero_Libraries::setTimestampLock($affectedLibraries, $timestamp)) {
-				throw new Exception("Library timestamp already used", Z_ERROR_LIBRARY_TIMESTAMP_ALREADY_USED);
-			}
-			
-			$modifiedItems = array();
-			$skipCreators = [];
-			
-			// Add/update creators
-			if ($xml->creators) {
-				// DOM
-				$keys = array();
-				$xmlElements = dom_import_simplexml($xml->creators);
-				$xmlElements = $xmlElements->getElementsByTagName('creator');
-				Zotero_DB::query("SET foreign_key_checks = 0");
-				try {
-					$addedLibraryIDs = array();
-					$addedCreatorDataHashes = array();
-					foreach ($xmlElements as $xmlElement) {
-						$key = $xmlElement->getAttribute('key');
-						if (isset($keys[$key])) {
-							throw new Exception("Creator $key already processed");
-						}
-						$keys[$key] = true;
-						
-						$creatorObj = Zotero_Creators::convertXMLToCreator($xmlElement);
-						if (Zotero_Utilities::unicodeTrim($creatorObj->firstName) === ''
-								&& Zotero_Utilities::unicodeTrim($creatorObj->lastName) === '') {
-							// Remember the empty creator, since it might be referenced without
-							// data within an item and would otherwise be missing
-							if (empty($skipCreators[$creatorObj->libraryID])) {
-								$skipCreators[$creatorObj->libraryID] = [];
-							}
-							$skipCreators[$creatorObj->libraryID][] = $creatorObj->key;
-							continue;
-						}
-						$addedLibraryIDs[] = $creatorObj->libraryID;
-						
-						$changed = $creatorObj->save($userID);
-						
-						// If the creator changed, we need to update all linked items
-						if ($changed) {
-							$modifiedItems = array_merge(
-								$modifiedItems,
-								$creatorObj->getLinkedItems()
-							);
-						}
-					}
-				}
-				catch (Exception $e) {
-					Zotero_DB::query("SET foreign_key_checks = 1");
-					throw ($e);
-				}
-				Zotero_DB::query("SET foreign_key_checks = 1");
-				unset($keys);
-				unset($xml->creators);
-				
-				//
-				// Manual foreign key checks
-				//
-				// libraryID
-				foreach (array_unique($addedLibraryIDs) as $addedLibraryID) {
-					$shardID = Zotero_Shards::getByLibraryID($addedLibraryID);
-					$sql = "SELECT COUNT(*) FROM shardLibraries WHERE libraryID=?";
-					if (!Zotero_DB::valueQuery($sql, $addedLibraryID, $shardID)) {
-						throw new Exception("libraryID inserted into `creators` not found in `shardLibraries` ($addedLibraryID, $shardID)");
-					}
-				}
-			}
-			
-			// Add/update items
-			$savedItems = array();
-			if ($xml->items) {
-				$childItems = array();
-				
-				// DOM
-				$xmlElements = dom_import_simplexml($xml->items);
-				$xmlElements = $xmlElements->getElementsByTagName('item');
-				foreach ($xmlElements as $xmlElement) {
-					$libraryID = (int) $xmlElement->getAttribute('libraryID');
-					$key = $xmlElement->getAttribute('key');
-					
-					if (isset($savedItems[$libraryID . "/" . $key])) {
-						throw new Exception("Item $libraryID/$key already processed");
-					}
-					
-					$itemObj = Zotero_Items::convertXMLToItem($xmlElement, $skipCreators);
-					
-					if (!$itemObj->getSourceKey()) {
-						try {
-							$modified = $itemObj->save($userID);
-							if ($modified) {
-								$savedItems[$libraryID . "/" . $key] = true;
-							}
-						}
-						catch (Exception $e) {
-							if (strpos($e->getMessage(), 'libraryIDs_do_not_match') !== false) {
-								throw new Exception($e->getMessage() . " ($key)");
-							}
-							throw ($e);
-						}
-					}
-					else {
-						$childItems[] = $itemObj;
-					}
-				}
-				unset($xml->items);
-				
-				while ($childItem = array_shift($childItems)) {
-					$libraryID = $childItem->libraryID;
-					$key = $childItem->key;
-					if (isset($savedItems[$libraryID . "/" . $key])) {
-						throw new Exception("Item $libraryID/$key already processed");
-					}
-					
-					$modified = $childItem->save($userID);
-					if ($modified) {
-						$savedItems[$libraryID . "/" . $key] = true;
-					}
-				}
-			}
-			
-			// Add/update collections
-			if ($xml->collections) {
-				$collections = array();
-				$collectionSets = array();
-				
-				// DOM
-				// Build an array of unsaved collection objects and the keys of child items
-				$keys = array();
-				$xmlElements = dom_import_simplexml($xml->collections);
-				$xmlElements = $xmlElements->getElementsByTagName('collection');
-				foreach ($xmlElements as $xmlElement) {
-					$key = $xmlElement->getAttribute('key');
-					if (isset($keys[$key])) {
-						throw new Exception("Collection $key already processed");
-					}
-					$keys[$key] = true;
-					
-					$collectionObj = Zotero_Collections::convertXMLToCollection($xmlElement);
-					
-					$xmlItems = $xmlElement->getElementsByTagName('items')->item(0);
-					
-					// Fix an error if there's leading or trailing whitespace,
-					// which was possible in 2.0.3
-					if ($xmlItems) {
-						$xmlItems = trim($xmlItems->nodeValue);
-					}
-					
-					$arr = array(
-						'obj' => $collectionObj,
-						'items' => $xmlItems ? explode(' ', $xmlItems) : array()
-					);
-					$collections[] = $collectionObj;
-					$collectionSets[] = $arr;
-				}
-				unset($keys);
-				unset($xml->collections);
-				
-				self::saveCollections($collections, $userID);
-				unset($collections);
-				
-				// Set child items
-				foreach ($collectionSets as $collection) {
-					// Child items
-					if (isset($collection['items'])) {
-						$ids = array();
-						foreach ($collection['items'] as $key) {
-							$item = Zotero_Items::getByLibraryAndKey($collection['obj']->libraryID, $key);
-							if (!$item) {
-								throw new Exception("Child item '$key' of collection {$collection['obj']->id} not found", Z_ERROR_ITEM_NOT_FOUND);
-							}
-							$ids[] = $item->id;
-						}
-						$collection['obj']->setItems($ids);
-					}
-				}
-				unset($collectionSets);
-			}
-			
-			// Add/update saved searches
-			if ($xml->searches) {
-				$searches = array();
-				$keys = array();
-				
-				foreach ($xml->searches->search as $xmlElement) {
-					$key = (string) $xmlElement['key'];
-					if (isset($keys[$key])) {
-						throw new Exception("Search $key already processed");
-					}
-					$keys[$key] = true;
-					
-					$searchObj = Zotero_Searches::convertXMLToSearch($xmlElement);
-					$searchObj->save($userID);
-				}
-				unset($xml->searches);
-			}
-			
-			// Add/update tags
-			if ($xml->tags) {
-				$keys = array();
-				
-				// DOM
-				$xmlElements = dom_import_simplexml($xml->tags);
-				$xmlElements = $xmlElements->getElementsByTagName('tag');
-				foreach ($xmlElements as $xmlElement) {
-					// TEMP
-					$tagItems = $xmlElement->getElementsByTagName('items');
-					if ($tagItems->length && $tagItems->item(0)->nodeValue == "") {
-						error_log("Skipping tag with no linked items");
-						continue;
-					}
-					
-					$libraryID = (int) $xmlElement->getAttribute('libraryID');
-					$key = $xmlElement->getAttribute('key');
-					
-					$lk = $libraryID . "/" . $key;
-					if (isset($keys[$lk])) {
-						throw new Exception("Tag $lk already processed");
-					}
-					$keys[$lk] = true;
-					
-					$itemKeysToUpdate = array();
-					$tagObj = Zotero_Tags::convertXMLToTag($xmlElement, $itemKeysToUpdate);
-					
-					// We need to update removed items, added items, and,
-					// if the tag itself has changed, existing items
-					$modifiedItems = array_merge(
-						$modifiedItems,
-						array_map(
-							function ($key) use ($libraryID) {
-								return $libraryID . "/" . $key;
-							},
-							$itemKeysToUpdate
-						)
-					);
-					
-					$tagObj->save($userID, true);
-				}
-				unset($keys);
-				unset($xml->tags);
-			}
-			
-			// Add/update relations
-			if ($xml->relations) {
-				// DOM
-				$xmlElements = dom_import_simplexml($xml->relations);
-				$xmlElements = $xmlElements->getElementsByTagName('relation');
-				foreach ($xmlElements as $xmlElement) {
-					$relationObj = Zotero_Relations::convertXMLToRelation($xmlElement, $userLibraryID);
-					if ($relationObj->exists()) {
-						continue;
-					}
-					$relationObj->save($userID);
-				}
-				unset($keys);
-				unset($xml->relations);
-			}
-			
-			// Add/update settings
-			if ($xml->settings) {
-				// DOM
-				$xmlElements = dom_import_simplexml($xml->settings);
-				$xmlElements = $xmlElements->getElementsByTagName('setting');
-				foreach ($xmlElements as $xmlElement) {
-					$settingObj = Zotero_Settings::convertXMLToSetting($xmlElement);
-					$settingObj->save($userID);
-				}
-				unset($xml->settings);
-			}
-			
-			if ($xml->fulltexts) {
-				// DOM
-				$xmlElements = dom_import_simplexml($xml->fulltexts);
-				$xmlElements = $xmlElements->getElementsByTagName('fulltext');
-				foreach ($xmlElements as $xmlElement) {
-					Zotero_FullText::indexFromXML($xmlElement, $userID);
-				}
-				unset($xml->fulltexts);
-			}
-			
-			// TODO: loop
-			if ($xml->deleted) {
-				// Delete collections
-				if ($xml->deleted->collections) {
-					Zotero_Collections::deleteFromXML($xml->deleted->collections, $userID);
-				}
-				
-				// Delete items
-				if ($xml->deleted->items) {
-					Zotero_Items::deleteFromXML($xml->deleted->items, $userID);
-				}
-				
-				// Delete creators
-				if ($xml->deleted->creators) {
-					Zotero_Creators::deleteFromXML($xml->deleted->creators, $userID);
-				}
-				
-				// Delete saved searches
-				if ($xml->deleted->searches) {
-					Zotero_Searches::deleteFromXML($xml->deleted->searches, $userID);
-				}
-				
-				// Delete tags
-				if ($xml->deleted->tags) {
-					$xmlElements = dom_import_simplexml($xml->deleted->tags);
-					$xmlElements = $xmlElements->getElementsByTagName('tag');
-					foreach ($xmlElements as $xmlElement) {
-						$libraryID = (int) $xmlElement->getAttribute('libraryID');
-						$key = $xmlElement->getAttribute('key');
-						
-						$tagObj = Zotero_Tags::getByLibraryAndKey($libraryID, $key);
-						if (!$tagObj) {
-							continue;
-						}
-						// We need to update all items on the deleted tag
-						$modifiedItems = array_merge(
-							$modifiedItems,
-							array_map(
-								function ($key) use ($libraryID) {
-									return $libraryID . "/" . $key;
-								},
-								$tagObj->getLinkedItems(true)
-							)
-						);
-					}
-					
-					Zotero_Tags::deleteFromXML($xml->deleted->tags, $userID);
-				}
-				
-				// Delete relations
-				if ($xml->deleted->relations) {
-					Zotero_Relations::deleteFromXML($xml->deleted->relations, $userID);
-				}
-				
-				// Delete relations
-				if ($xml->deleted->settings) {
-					Zotero_Settings::deleteFromXML($xml->deleted->settings, $userID);
-				}
-			}
-			
-			$toUpdate = array();
-			foreach ($modifiedItems as $item) {
-				// libraryID/key string
-				if (is_string($item)) {
-					if (isset($savedItems[$item])) {
-						continue;
-					}
-					$savedItems[$item] = true;
-					list($libraryID, $key) = explode("/", $item);
-					$item = Zotero_Items::getByLibraryAndKey($libraryID, $key);
-					if (!$item) {
-						// Item was deleted
-						continue;
-					}
-				}
-				// Zotero_Item
-				else {
-					$lk = $item->libraryID . "/" . $item->key;
-					if (isset($savedItems[$lk])) {
-						continue;
-					}
-					$savedItems[$lk] = true;
-				}
-				$toUpdate[] = $item;
-			}
-			Zotero_Items::updateVersions($toUpdate, $userID);
-			unset($savedItems);
-			unset($modifiedItems);
-			
-			try {
-				self::removeUploadProcess($processID);
-			}
-			catch (Exception $e) {
-				if (strpos($e->getMessage(), 'MySQL server has gone away') !== false) {
-					// Reconnect
-					error_log("Reconnecting to MySQL master");
-					Zotero_DB::close();
-					self::removeUploadProcess($processID);
-				}
-				else {
-					throw ($e);
-				}
-			}
-			
-			// Send notifications for changed libraries
-			foreach ($affectedLibraries as $libraryID) {
-				Zotero_Notifier::trigger('modify', 'library', $libraryID);
-			}
-			
-			Zotero_DB::commit();
-			
-			if ($profile) {
-				$shardID = Zotero_Shards::getByUserID($userID);
-				Zotero_DB::profileEnd($shardID);
-			}
-			
-			// Return timestamp + 1, to keep the next /updated call
-			// (using >= timestamp) from returning this data
-			return $timestamp + 1;
-		}
-		catch (Exception $e) {
-			Zotero_DB::rollback(true);
-			self::removeUploadProcess($processID);
-			throw $e;
-		}
-	}
-	
-	
-	public static function getTimestampParts($timestamp) {
-		$float = explode('.', $timestamp);
-		$timestamp = $float[0];
-		$timestampMS = isset($float[1]) ? substr($float[1], 0, 5) : 0;
-		if ($timestampMS > 65535) {
-			$timestampMS = substr($float[1], 0, 4);
-		}
-		return array($timestamp, (int) $timestampMS);
-	}
-	
-	
-	
-	/**
-	 * Recursively save collections from the top down
-	 */
-	private static function saveCollections($collections, $userID) {
-		$originalLength = sizeOf($collections);
-		$unsaved = array();
-		
-		$toSave = array();
-		for ($i=0, $len=sizeOf($collections); $i<$len; $i++) {
-			$toSave[$collections[$i]->key] = true;
-		}
-		for ($i=0; $i<sizeOf($collections); $i++) {
-			$collection = $collections[$i];
-			$key = $collection->key;
-			
-			$parentKey = $collection->parentKey;
-			// Top-level collection, so save
-			if (!$parentKey) {
-				$collection->save($userID);
-				unset($toSave[$key]);
-				continue;
-			}
-			$parentCollection = Zotero_Collections::getByLibraryAndKey($collection->libraryID, $parentKey);
-			// Parent collection exists and doesn't need to be saved, so save
-			if ($parentCollection && empty($toSave[$parentCollection->key])) {
-				$collection->save($userID);
-				unset($toSave[$key]);
-				continue;
-			}
-			// Add to unsaved list
-			$unsaved[] = $collection;
-			continue;
-		}
-		
-		if ($unsaved) {
-			if ($originalLength == sizeOf($unsaved)) {
-				throw new Exception("Incomplete collection hierarchy cannot be saved", Z_ERROR_COLLECTION_NOT_FOUND);
-			}
-			
-			self::saveCollections($unsaved, $userID);
-		}
-	}
-	
-	
-	private static function addDownloadProcess($syncDownloadQueueID, $syncDownloadProcessID) {
-		$sql = "UPDATE syncDownloadQueue SET syncDownloadProcessID=? WHERE syncDownloadQueueID=?";
-		Zotero_DB::query($sql, array($syncDownloadProcessID, $syncDownloadQueueID));
-	}
-	
-	/**
-	 * Add sync process and associated locks to database
-	 */
-	private static function addUploadProcess($userID, $libraryIDs, $syncQueueID=null, $syncProcessID=null) {
-		Zotero_DB::beginTransaction();
-		
-		$syncProcessID = $syncProcessID ? $syncProcessID : Zotero_ID::getBigInt();
-		$sql = "INSERT INTO syncProcesses (syncProcessID, userID) VALUES (?, ?)";
-		try {
-			Zotero_DB::query($sql, array($syncProcessID, $userID));
-		}
-		catch (Exception $e) {
-			$sql = "SELECT CONCAT(syncProcessID,' ',userID,' ',started) FROM syncProcesses WHERE userID=?";
-			$val = Zotero_DB::valueQuery($sql, $userID);
-			Z_Core::logError($val);
-		}
-		
-		if ($libraryIDs) {
-			$sql = "INSERT INTO syncProcessLocks VALUES ";
-			$sql .= implode(', ', array_fill(0, sizeOf($libraryIDs), '(?,?)'));
-			$params = array();
-			foreach ($libraryIDs as $libraryID) {
-				$params[] = $syncProcessID;
-				$params[] = $libraryID;
-			}
-			Zotero_DB::query($sql, $params);
-		}
-		
-		// Record the process id in the queue entry, if given
-		if ($syncQueueID) {
-			$sql = "UPDATE syncUploadQueue SET syncProcessID=? WHERE syncUploadQueueID=?";
-			Zotero_DB::query($sql, array($syncProcessID, $syncQueueID));
-		}
-		
-		Zotero_DB::commit();
-		
-		return $syncProcessID;
-	}
-	
-	
-	private static function countDeletedObjectKeys($userID, $timestamp, $updatedLibraryIDs) {
-		/*
-		$sql = "SELECT version FROM version WHERE schema='syncdeletelog'";
-		$syncLogStart = Zotero_DB::valueQuery($sql);
-		if (!$syncLogStart) {
-			throw ('Sync log start time not found');
-		}
-		*/
-		
-		/*
-		// Last sync time is before start of log
-		if ($lastSyncDate && new Date($syncLogStart * 1000) > $lastSyncDate) {
-			return -1;
-		}
-		*/
-		
-		$shardLibraryIDs = array();
-		
-		// Personal library
-		$shardID = Zotero_Shards::getByUserID($userID);
-		$libraryID = Zotero_Users::getLibraryIDFromUserID($userID);
-		if (in_array($libraryID, $updatedLibraryIDs)) {
-			$shardLibraryIDs[$shardID] = array($libraryID);
-		}
-		
-		// Group libraries
-		$groupIDs = Zotero_Groups::getUserGroups($userID);
-		if ($groupIDs) {
-			// Separate groups into shards for querying
-			foreach ($groupIDs as $groupID) {
-				$libraryID = Zotero_Groups::getLibraryIDFromGroupID($groupID);
-				// If library hasn't changed, skip
-				if (!in_array($libraryID, $updatedLibraryIDs)) {
-					continue;
-				}
-				$shardID = Zotero_Shards::getByLibraryID($libraryID);
-				if (!isset($shardLibraryIDs[$shardID])) {
-					$shardLibraryIDs[$shardID] = array();
-				}
-				$shardLibraryIDs[$shardID][] = $libraryID;
-			}
-		}
-		
-		// Send query at each shard
-		$rows = array();
-		$count = 0;
-		foreach ($shardLibraryIDs as $shardID=>$libraryIDs) {
-			$sql = "SELECT COUNT(*) FROM syncDeleteLogKeys WHERE libraryID IN ("
-					. implode(', ', array_fill(0, sizeOf($libraryIDs), '?'))
-					. ") "
-					// API only
-					. "AND objectType != 'tagName'";
-			$params = $libraryIDs;
-			if ($timestamp) {
-				$sql .= " AND timestamp >= FROM_UNIXTIME(?)";
-				$params[] = $timestamp;
-			}
-			$count += Zotero_DB::valueQuery($sql, $params, $shardID);
-		}
-		
-		return $count;
-	}
-	
-	
-	/**
-	 * @param	int		$userID		User id
-	 * @param	int		$timestamp	Unix timestamp of last sync
-	 * @return	mixed	Returns array of objects with properties
-	 *					'libraryID', 'id', and 'rowType' ('key' or 'id'),
-	 * 					FALSE if none, or -1 if last sync time is before start of log
-	 */
-	private static function getDeletedObjectKeys($userID, $timestamp, $includeAllUserObjects=false) {
-		/*
-		$sql = "SELECT version FROM version WHERE schema='syncdeletelog'";
-		$syncLogStart = Zotero_DB::valueQuery($sql);
-		if (!$syncLogStart) {
-			throw ('Sync log start time not found');
-		}
-		*/
-		
-		/*
-		// Last sync time is before start of log
-		if ($lastSyncDate && new Date($syncLogStart * 1000) > $lastSyncDate) {
-			return -1;
-		}
-		*/
-		
-		// Personal library
-		$shardID = Zotero_Shards::getByUserID($userID);
-		$libraryID = Zotero_Users::getLibraryIDFromUserID($userID);
-		$shardLibraryIDs[$shardID] = array($libraryID);
-		
-		// Group libraries
-		if ($includeAllUserObjects) {
-			$groupIDs = Zotero_Groups::getUserGroups($userID);
-			if ($groupIDs) {
-				// Separate groups into shards for querying
-				foreach ($groupIDs as $groupID) {
-					$libraryID = Zotero_Groups::getLibraryIDFromGroupID($groupID);
-					$shardID = Zotero_Shards::getByLibraryID($libraryID);
-					if (!isset($shardLibraryIDs[$shardID])) {
-						$shardLibraryIDs[$shardID] = array();
-					}
-					$shardLibraryIDs[$shardID][] = $libraryID;
-				}
-			}
-		}
-		
-		// Send query at each shard
-		$rows = array();
-		foreach ($shardLibraryIDs as $shardID=>$libraryIDs) {
-			$sql = "SELECT libraryID, objectType, `key`, timestamp
-					FROM syncDeleteLogKeys WHERE libraryID IN ("
-					. implode(', ', array_fill(0, sizeOf($libraryIDs), '?'))
-					. ")"
-					// API only
-					. " AND objectType != 'tagName'";
-			$params = $libraryIDs;
-			if ($timestamp) {
-				$sql .= " AND timestamp >= FROM_UNIXTIME(?)";
-				$params[] = $timestamp;
-			}
-			$sql .= " ORDER BY timestamp";
-			$shardRows = Zotero_DB::query($sql, $params, $shardID);
-			if ($shardRows) {
-				$rows = array_merge($rows, $shardRows);
-			}
-		}
-		
-		if (!$rows) {
-			return false;
-		}
-		
-		$deletedIDs = array();
-		foreach (Zotero_DataObjects::$classicObjectTypes as $syncObject) {
-			$deletedIDs[strtolower($syncObject['plural'])] = array();
-		}
-		foreach ($rows as $row) {
-			$type = strtolower(Zotero_DataObjects::$classicObjectTypes[$row['objectType']]['plural']);
-			$deletedIDs[$type][] = array(
-				'libraryID' => $row['libraryID'],
-				'key' => $row['key']
-			);
-		}
-		
-		return $deletedIDs;
-	}
-	
-	
-	private static function getDeletedObjectIDs($userID, $timestamp, $includeAllUserObjects=false) {
-		/*
-		$sql = "SELECT version FROM version WHERE schema='syncdeletelog'";
-		$syncLogStart = Zotero_DB::valueQuery($sql);
-		if (!$syncLogStart) {
-			throw ('Sync log start time not found');
-		}
-		*/
-		
-		/*
-		// Last sync time is before start of log
-		if ($lastSyncDate && new Date($syncLogStart * 1000) > $lastSyncDate) {
-			return -1;
-		}
-		*/
-		
-		// Personal library
-		$shardID = Zotero_Shards::getByUserID($userID);
-		$libraryID = Zotero_Users::getLibraryIDFromUserID($userID);
-		$shardLibraryIDs[$shardID] = array($libraryID);
-		
-		// Group libraries
-		if ($includeAllUserObjects) {
-			$groupIDs = Zotero_Groups::getUserGroups($userID);
-			if ($groupIDs) {
-				// Separate groups into shards for querying
-				foreach ($groupIDs as $groupID) {
-					$libraryID = Zotero_Groups::getLibraryIDFromGroupID($groupID);
-					$shardID = Zotero_Shards::getByLibraryID($libraryID);
-					if (!isset($shardLibraryIDs[$shardID])) {
-						$shardLibraryIDs[$shardID] = array();
-					}
-					$shardLibraryIDs[$shardID][] = $libraryID;
-				}
-			}
-		}
-		
-		// Send query at each shard
-		$rows = array();
-		foreach ($shardLibraryIDs as $shardID=>$libraryIDs) {
-			$sql = "SELECT libraryID, objectType, id, timestamp
-					FROM syncDeleteLogIDs WHERE libraryID IN ("
-					. implode(', ', array_fill(0, sizeOf($libraryIDs), '?'))
-					. ")";
-			$params = $libraryIDs;
-			if ($timestamp) {
-				// Send any entries from before these were being properly sent
-				if ($timestamp < 1260778500) {
-					$sql .= " AND (timestamp >= FROM_UNIXTIME(?) OR timestamp BETWEEN 1257968068 AND FROM_UNIXTIME(?))";
-					$params[] = $timestamp;
-					$params[] = 1260778500;
-				}
-				else {
-					$sql .= " AND timestamp >= FROM_UNIXTIME(?)";
-					$params[] = $timestamp;
-				}
-			}
-			$sql .= " ORDER BY timestamp";
-			
-			$shardRows = Zotero_DB::query($sql, $params, $shardID);
-			if ($shardRows) {
-				$rows = array_merge($rows, $shardRows);
-			}
-		}
-		
-		if (!$rows) {
-			return false;
-		}
-		
-		$deletedIDs = array(
-			'groups' => array()
-		);
-		
-		foreach ($rows as $row) {
-			$type = $row['objectType'] . 's';
-			$deletedIDs[$type][] = $row['id'];
-		}
-		
-		return $deletedIDs;
-	}
-}
-?>
diff --git a/model/Tag.inc.php b/model/Tag.inc.php
deleted file mode 100644
index 432a93c2..00000000
--- a/model/Tag.inc.php
+++ /dev/null
@@ -1,782 +0,0 @@
-<?
-/*
-    ***** 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) use ($libraryID) {
-			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;
-	}
-	
-	
-	/**
-	 * Converts a Zotero_Tag object to a SimpleXMLElement item
-	 *
-	 * @param	object				$item		Zotero_Tag object
-	 * @return	SimpleXMLElement				Tag data as SimpleXML element
-	 */
-	public function toXML($syncMode=false) {
-		if (!$this->loaded) {
-			$this->load();
-		}
-		
-		$xml = new SimpleXMLElement('<tag/>');
-		
-		$xml['libraryID'] = $this->libraryID;
-		$xml['key'] = $this->key;
-		$name = $this->name;
-		if (trim($name) === "") {
-			error_log("Empty tag " . $this->libraryID . "/" . $this->key);
-			$name = json_decode('"\uFFFD"');
-		}
-		$xml['name'] = $name;
-		$xml['dateAdded'] = $this->dateAdded;
-		$xml['dateModified'] = $this->dateModified;
-		if ($this->type) {
-			$xml['type'] = $this->type;
-		}
-		
-		if ($syncMode) {
-			$itemKeys = $this->getLinkedItems(true);
-			if ($itemKeys) {
-				$xml->items = implode(" ", $itemKeys);
-			}
-		}
-		
-		return $xml;
-	}
-	
-	
-	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);
-	}
-}
-?>
diff --git a/model/Tags.inc.php b/model/Tags.inc.php
deleted file mode 100644
index 1a37b663..00000000
--- a/model/Tags.inc.php
+++ /dev/null
@@ -1,326 +0,0 @@
-<?
-/*
-    ***** 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 = !empty($params['tag']) ? explode(' || ', $params['tag']): array();
-		
-		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 (!empty($params['q'])) {
-			if (!is_array($params['q'])) {
-				$params['q'] = array($params['q']);
-			}
-			foreach ($params['q'] as $q) {
-				$sql .= "AND name LIKE ? ";
-				$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;
-	}
-	
-	
-	/*public static function getDataValuesFromXML(DOMDocument $doc) {
-		$xpath = new DOMXPath($doc);
-		$attr = $xpath->evaluate('//tags/tag/@name');
-		$vals = array();
-		foreach ($attr as $a) {
-			$vals[] = $a->value;
-		}
-		$vals = array_unique($vals);
-		return $vals;
-	}*/
-	
-	
-	public static function getLongDataValueFromXML(DOMDocument $doc) {
-		$xpath = new DOMXPath($doc);
-		$attr = $xpath->evaluate('//tags/tag[string-length(@name) > ' . self::$maxLength . ']/@name');
-		return $attr->length ? $attr->item(0)->value : false;
-	}
-	
-	
-	/**
-	 * Converts a DOMElement item to a Zotero_Tag object
-	 *
-	 * @param	DOMElement			$xml		Tag data as DOMElement
-	 * @param	int					$libraryID	Library ID
-	 * @return	Zotero_Tag						Zotero tag object
-	 */
-	public static function convertXMLToTag(DOMElement $xml, &$itemKeysToUpdate) {
-		$libraryID = (int) $xml->getAttribute('libraryID');
-		$tag = self::getByLibraryAndKey($libraryID, $xml->getAttribute('key'));
-		if (!$tag) {
-			$tag = new Zotero_Tag;
-			$tag->libraryID = $libraryID;
-			$tag->key = $xml->getAttribute('key');
-		}
-		$tag->name = $xml->getAttribute('name');
-		$type = (int) $xml->getAttribute('type');
-		$tag->type = $type ? $type : 0;
-		$tag->dateAdded = $xml->getAttribute('dateAdded');
-		$tag->dateModified = $xml->getAttribute('dateModified');
-		
-		$dataChanged = $tag->hasChanged();
-		
-		$itemKeys = $xml->getElementsByTagName('items');
-		$oldKeys = $tag->getLinkedItems(true);
-		if ($itemKeys->length) {
-			$newKeys = explode(' ', $itemKeys->item(0)->nodeValue);
-		}
-		else {
-			$newKeys = array();
-		}
-		$addKeys = array_diff($newKeys, $oldKeys);
-		$removeKeys = array_diff($oldKeys, $newKeys);
-		
-		// If the data has changed, all old and new items need to change
-		if ($dataChanged) {
-			$itemKeysToUpdate = array_merge($oldKeys, $addKeys);
-		}
-		// Otherwise, only update items that are being added or removed
-		else {
-			$itemKeysToUpdate = array_merge($addKeys, $removeKeys);
-		}
-		
-		$tag->setLinkedItems($newKeys);
-		return $tag;
-	}
-	
-	
-	/**
-	 * Converts a Zotero_Tag object to a SimpleXMLElement item
-	 *
-	 * @param	object				$item		Zotero_Tag object
-	 * @return	SimpleXMLElement				Tag data as SimpleXML element
-	 */
-	public static function convertTagToXML(Zotero_Tag $tag, $syncMode=false) {
-		return $tag->toXML($syncMode);
-	}
-}
-?>
diff --git a/model/ToolkitVersionComparator.inc.php b/model/ToolkitVersionComparator.inc.php
deleted file mode 100644
index 63663cf1..00000000
--- a/model/ToolkitVersionComparator.inc.php
+++ /dev/null
@@ -1,294 +0,0 @@
-<?
-/* ***** BEGIN LICENSE BLOCK *****
-* Version: MPL 1.1/GPL 2.0/LGPL 2.1
-*
-* The contents of this file are subject to the Mozilla Public License Version
-* 1.1 (the "License"); you may not use this file except in compliance with
-* the License. You may obtain a copy of the License at
-* http://www.mozilla.org/MPL/
-*
-* Software distributed under the License is distributed on an "AS IS" basis,
-* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
-* for the specific language governing rights and limitations under the
-* License.
-*
-* The Original Code is Java XPCOM Bindings.
-*
-* The Initial Developer of the Original Code is
-* IBM Corporation.
-* Portions created by the Initial Developer are Copyright (C) 2005
-* IBM Corporation. All Rights Reserved.
-*
-* Contributor(s):
-*   Javier Pedemonte (jhpedemonte@gmail.com)
-*
-* Ported to PHP by Dan Stillman (dstillman _at_ zotero.org)
-*
-* Alternatively, the contents of this file may be used under the terms of
-* either the GNU General Public License Version 2 or later (the "GPL"), or
-* the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
-* in which case the provisions of the GPL or the LGPL are applicable instead
-* of those above. If you wish to allow use of your version of this file only
-* under the terms of either the GPL or the LGPL, and not to allow others to
-* use your version of this file under the terms of the MPL, indicate your
-* decision by deleting the provisions above and replace them with the notice
-* and other provisions required by the GPL or the LGPL. If you do not delete
-* the provisions above, a recipient may use your version of this file under
-* the terms of any one of the MPL, the GPL or the LGPL.
-*
-* ***** END LICENSE BLOCK ***** */
-
-
-//
-// Unit tests
-//
-// (compare results to version order in specifications below)
-//
-/*
-$all = array('1.0pre1', '1.0pre2', '1.0', '1.0.0', '1.0.0.0', '1.1pre', '1.1pre0', '1.0+',
-	'1.1pre1a', '1.1pre1', '1.1pre10a', '1.1pre10');
-
-for ($i=0; $i<sizeOf($all); $i++) {
-	for ($j=0; $j<sizeOf($all); $j++) {
-		$a = $all[$i];
-		$b = $all[$j];
-		$compare = ToolkitVersionComparator::compare($a, $b);
-		if ($compare<0) {
-			echo $a . ' is less than ' . $b;
-		}
-		if ($compare==0) {
-			echo $a . ' equals ' . $b;
-		}
-		if ($compare>0) {
-			echo $a . ' is greater than ' . $b;
-		}
-		echo "\n";
-	}
-	echo "\n\n";
-}
-*/
-
-
-/**
- * Implements Mozilla Toolkit's nsIVersionComparator
- *
- * Version strings are dot-separated sequences of version-parts.
- *
- * A version-part consists of up to four parts, all of which are optional:
- * <number-a><string-b><number-c><string-d (everything else)>
- * A version-part may also consist of a single asterisk "*" which indicates
- * "infinity".
- *
- * Numbers are base-10, and are zero if left out.
- * Strings are compared bytewise.
- *
- * For additional backwards compatibility, if "string-b" is "+" then
- * "number-a" is incremented by 1 and "string-b" becomes "pre".
- *
- * 1.0pre1
- * < 1.0pre2  
- *   < 1.0 == 1.0.0 == 1.0.0.0
- *     < 1.1pre == 1.1pre0 == 1.0+
- *       < 1.1pre1a
- *         < 1.1pre1
- *           < 1.1pre10a
- *             < 1.1pre10
- *
- * Although not required by this interface, it is recommended that
- * numbers remain within the limits of a signed char, i.e. -127 to 128.
- */
-class ToolkitVersionComparator {
-	public static function compare($a, $b) {
-		do {
-			$va = new ToolkitVersionPart();
-			$vb = new ToolkitVersionPart();
-			$a = self::parseVersionPart($a, $va);
-			$b = self::parseVersionPart($b, $vb);
-			
-			$result = self::compareVersionPart($va, $vb);
-			
-			if ($result != 0){
-				break;
-			}
-		}
-		while ($a != null || $b != null);
-		
-		return $result;
-	}
-	
-	
-	private static function parseVersionPart($aVersion, ToolkitVersionPart $result) {
-		if ($aVersion === null || strlen($aVersion) == 0) {
-			return $aVersion;
-		}
-		
-		$tok = explode(".", trim($aVersion));
-		$part = $tok[0];
-		
-		if ($part == "*") {
-			$result->numA = 9999999999;
-			$result->strB = "";
-		}
-		else {
-			$vertok = new ToolkitVersionPartTokenizer($part);
-			$next = $vertok->nextToken();
-			if (is_numeric($next)){
-				$result->numA = $next;
-			}
-			else {
-				$result->numA = 0;
-			}
-			
-			if ($vertok->hasMoreElements()) {
-				$str = $vertok->nextToken();
-				// if part is of type "<num>+"
-				if ($str[0] == '+') {
-					$result->numA++;
-					$result->strB = "pre";
-				}
-				else {
-					// else if part is of type "<num><alpha>..."
-					$result->strB = $str;
-					
-					if ($vertok->hasMoreTokens()) {
-						$next = $vertok->nextToken();
-						if (is_numeric($next)){
-							$result->numC = $next;
-						}
-						else {
-							$result->numC = 0;
-						}
-						if ($vertok->hasMoreTokens()) {
-							$result->extraD = $vertok->getRemainder();
-						}
-					}
-				}
-			}
-		}
-		
-		if (sizeOf($tok)>1) {
-			// return everything after "."
-			return substr($aVersion, strlen($part) + 1);
-		}
-		return null;
-	}
-	
-	
-	private static function compareVersionPart(ToolkitVersionPart $va, ToolkitVersionPart $vb) {
-		$res = self::compareInt($va->numA, $vb->numA);
-		if ($res != 0) {
-			return $res;
-		}
-		
-		$res = self::compareString($va->strB, $vb->strB);
-		if ($res != 0) {
-			return $res;
-		}
-		
-		$res = self::compareInt($va->numC, $vb->numC);
-		if ($res != 0) {
-			return $res;
-		}
-		
-		return self::compareString($va->extraD, $vb->extraD);
-	}
-	
-	
-	private static function compareInt($n1, $n2) {
-		return $n1 - $n2;
-	}
-	
-	
-	private static function compareString($str1, $str2) {
-		// any string is *before* no string
-		if ($str1 === null) {
-			return ($str2 !== null) ? 1 : 0;
-		}
-		
-		if ($str2 === null) {
-			return -1;
-		}
-		
-		return strcmp($str1, $str2);
-	}
-
-	
-}
-
-
-class ToolkitVersionPart {
-	public $numA = 0;
-	public $strB = null;
-	public $numC = 0;
-	public $extraD = null;
-}
-
-
-/**
- * Specialized tokenizer for Mozilla version strings.  A token can
- * consist of one of the four sections of a version string:
- * <number-a><string-b><number-c><string-d (everything else)>
- */
-class ToolkitVersionPartTokenizer {
-	private $part = '';
-	
-	
-	public function __construct($aPart) {
-		$this->part = $aPart;
-	}
-	
-	
-	public function hasMoreElements() {
-		return strlen($this->part) != 0;
-	}
-	
-	
-	public function hasMoreTokens() {
-		return strlen($this->part) != 0;
-	}
-	
-	
-	public function nextElement() {
-		if (preg_match('/^[\+\-]?[0-9].*/', $this->part)) {
-			// if string starts with a number...
-			$index = 0;
-			if ($this->part[0] == '+' || $this->part[0] == '-') {
-				$index = 1;
-			}
-			while (($index < strlen($this->part)) && is_numeric($this->part[$index])) {
-				$index++;
-			}
-			$numPart = substr($this->part, 0, $index);
-			$this->part = substr($this->part, $index);
-			return $numPart;
-		}
-		else {
-			// ... or if this is the non-numeric part of version string
-			$index = 0;
-			while (($index < strlen($this->part)) && !is_numeric($this->part[$index])) {
-				$index++;
-			}
-			$alphaPart = substr($this->part, 0, $index);
-			$this->part = substr($this->part, $index);
-			return $alphaPart;
-		}
-	}
-	
-	
-	public function nextToken() {
-		return $this->nextElement();
-	}
-	
-	
-	/**
-	 * Returns what remains of the original string, without tokenization.  This
-	 * method is useful for getting the <string-d (everything else)>;
-	 * section of a version string.
-	 * 
-	 * @return remaining version string
-	 */
-	public function getRemainder() {
-		return $this->part;
-	}
-}
-?>
diff --git a/model/Translate.inc.php b/model/Translate.inc.php
deleted file mode 100644
index 22de9dac..00000000
--- a/model/Translate.inc.php
+++ /dev/null
@@ -1,232 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2011 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_Translate {
-	public static $exportFormats = array(
-		'bibtex',
-		'biblatex',
-		'bookmarks',
-		'coins',
-		'csljson',
-		'rdf_bibliontology',
-		'rdf_dc',
-		'rdf_zotero',
-		'mods',
-		'refer',
-		'ris',
-		'tei',
-		'wikipedia'
-	);
-	
-	/**
-	 * @param array[] $items Array of item JSON objects
-	 * @param string $requestParams Request parameters
-	 */
-	public static function doExport($items, $requestParams) {
-		$format = $requestParams['format'];
-		
-		if (!in_array($format, self::$exportFormats)) {
-			throw new Exception("Invalid export format '$format'");
-		}
-		
-		$jsonItems = array();
-		foreach ($items as $item) {
-			$arr = $item->toJSON(true, $requestParams);
-			$arr['uri'] = Zotero_URI::getItemURI($item);
-			$jsonItems[] = $arr;
-		}
-		
-		if (!$jsonItems) {
-			return array(
-				'body' => "",
-				// Stripping the Content-Type header (header_remove, "Content-Type:")
-				// in the API controller doesn't seem to be working, so send
-				// text/plain instead
-				'mimeType' => "text/plain"
-			);
-		}
-		
-		$json = json_encode($jsonItems);
-		
-		$servers = Z_CONFIG::$TRANSLATION_SERVERS;
-		
-		// Try servers in a random order
-		shuffle($servers);
-		
-		foreach ($servers as $server) {
-			$url = "http://$server/export?format=$format";
-			
-			$start = microtime(true);
-			
-			$ch = curl_init($url);
-			curl_setopt($ch, CURLOPT_POST, 1);
-			curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
-			curl_setopt($ch, CURLOPT_HTTPHEADER, array("Expect:", "Content-Type: application/json"));
-			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;
-			
-			$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-			$mimeType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
-			
-			if ($code != 200) {
-				$response = null;
-				Z_Core::logError("HTTP $code from translate server $server exporting items");
-				Z_Core::logError($response);
-				continue;
-			}
-			
-			if (!$response) {
-				$response = "";
-			}
-			
-			break;
-		}
-		
-		if ($response === null) {
-			StatsD::increment("translate.export.$format.error");
-			throw new Exception("Error exporting items");
-		}
-		
-		$export = array(
-			'body' => $response,
-			'mimeType' => $mimeType
-		);
-		
-		StatsD::increment("translate.export.$format.success");
-		return $export;
-	}
-	
-	
-	public static function doWeb($url, $sessionKey, $items=false) {
-		if (!$sessionKey) {
-			throw new Exception("Session key not provided");
-		}
-		
-		$servers = Z_CONFIG::$TRANSLATION_SERVERS;
-		
-		// Try servers in a random order
-		shuffle($servers);
-		
-		$cacheKey = 'sessionTranslationServer_' . $sessionKey;
-		
-		$json = [
-			"url" => $url,
-			"sessionid" => $sessionKey
-		];
-		
-		if ($items) {
-			$json['items'] = $items;
-			
-			// Send session requests to the same node
-			if ($server = Z_Core::$MC->get($cacheKey)) {
-				$servers = [$server];
-			}
-			else {
-				error_log("WARNING: Server not found for translation session");
-			}
-		}
-		
-		$json = json_encode($json);
-		
-		
-		foreach ($servers as $server) {
-			$serverURL = "http://$server/web";
-			
-			$start = microtime(true);
-			
-			$ch = curl_init($serverURL);
-			curl_setopt($ch, CURLOPT_POST, 1);
-			curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
-			curl_setopt($ch, CURLOPT_HTTPHEADER, array("Expect:", "Content-Type: application/json"));
-			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;
-			
-			$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-			$mimeType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
-			
-			if ($code != 200 && $code != 300) {
-				// For explicit errors, trust translation server and bail with response code
-				if ($code == 500 && strpos($response, "An error occurred during translation") !== false) {
-					error_log("Error translating $url");
-					return 500;
-				}
-				else if ($code == 501) {
-					error_log("No translators found for $url");
-					return 501;
-				}
-				
-				// If unknown error, log and try another server
-				$response = null;
-				Z_Core::logError("HTTP $code from translate server $server translating URL");
-				Z_Core::logError($response);
-				continue;
-			}
-			
-			if (!$response) {
-				$response = "";
-			}
-			
-			// Remember translation-server node for item selection
-			if ($code == 300) {
-				Z_Core::$MC->set($cacheKey, $server, 600);
-			}
-			break;
-		}
-		
-		if ($response === null) {
-			throw new Exception("Error from translation server");
-		}
-		
-		$response = json_decode($response);
-		
-		$obj = new stdClass;
-		// Multiple choices
-		if ($code == 300) {
-			$obj->select = $response;
-		}
-		// Saved items
-		else {
-			$obj->items = $response;
-		}
-		
-		return $obj;
-	}
-	
-	
-	public static function isExportFormat($format) {
-		return in_array($format, self::$exportFormats);
-	}
-}
diff --git a/model/URI.inc.php b/model/URI.inc.php
deleted file mode 100644
index ec9759fd..00000000
--- a/model/URI.inc.php
+++ /dev/null
@@ -1,186 +0,0 @@
-<?
-/*
-    ***** 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_URI {
-	public static function getBaseURI() {
-		return Z_CONFIG::$BASE_URI;
-	}
-	
-	public static function getBaseWWWURI() {
-		return Z_CONFIG::$WWW_BASE_URI;
-	}
-	
-	public static function getLibraryURI($libraryID, $www=false, $useSlug=false) {
-		$libraryType = Zotero_Libraries::getType($libraryID);
-		switch ($libraryType) {
-			case 'user':
-				$id = Zotero_Users::getUserIDFromLibraryID($libraryID);
-				return self::getUserURI($id, $www, $useSlug);
-			
-			// TEMP
-			case 'publications':
-				$id = Zotero_Users::getUserIDFromLibraryID($libraryID);
-				return self::getUserURI($id, $www, $useSlug) . "/publications";
-			
-			case 'group':
-				$id = Zotero_Groups::getGroupIDFromLibraryID($libraryID);
-				$group = Zotero_Groups::get($id);
-				return self::getGroupURI($group, $www, $useSlug);
-			
-			default:
-				throw new Exception("Invalid library type '$libraryType'");
-		}
-	}
-	
-	public static function getUserURI($userID, $www=false, $useSlug=false) {
-		if ($www) {
-			$username = Zotero_Users::getUsername($userID);
-			return self::getBaseWWWURI() . Zotero_Utilities::slugify($username);
-		}
-		if ($useSlug) {
-			$username = Zotero_Users::getUsername($userID);
-			return self::getBaseURI() . Zotero_Utilities::slugify($username);
-		}
-		return self::getBaseURI() . "users/$userID";
-	}
-	
-	public static function getItemURI(Zotero_Item $item, $www=false, $useSlug=false) {
-		if (!$item->libraryID) {
-			throw new Exception("Can't get URI for unsaved item");
-		}
-		return self::getLibraryURI($item->libraryID, $www, $useSlug) . "/items/$item->key";
-	}
-	
-	public static function getGroupURI(Zotero_Group $group, $www=false, $useSlug=false) {
-		if ($www) {
-			$slug = $group->slug;
-			if (!$slug) {
-				$slug = $group->id;
-			}
-			return self::getBaseWWWURI() . "groups/$slug";
-		}
-		if ($useSlug) {
-			$id = $group->slug;
-			if ($id === null) {
-				$id = $group->id;
-			}
-		}
-		else {
-			$id = $group->id;
-		}
-		return self::getBaseURI() . "groups/" . $id;
-	}
-	
-	public static function getGroupUserURI(Zotero_Group $group, $userID) {
-		return self::getGroupURI($group) . "/users/$userID";
-	}
-	
-	public static function getGroupItemURI(Zotero_Group $group, Zotero_Item $item) {
-		return self::getGroupURI($group) . "/items/$item->key";
-	}
-	
-	public static function getCollectionURI(Zotero_Collection $collection, $www=false) {
-		return self::getLibraryURI($collection->libraryID, true) . "/collections/$collection->key";
-	}
-	
-	public static function getCreatorURI(Zotero_Creator $creator) {
-		return self::getLibraryURI($creator->libraryID) . "/creators/$creator->key";
-	}
-	
-	public static function getSearchURI(Zotero_Search $search) {
-		return self::getLibraryURI($search->libraryID) . "/searches/$search->key";
-	}
-	
-	public static function getTagURI(Zotero_Tag $tag) {
-		return self::getLibraryURI($tag->libraryID) . "/tags/" . urlencode($tag->name);
-	}
-	
-	public static function getURIItem($itemURI) {
-		return self::getURIObject($itemURI, 'item');
-	}
-	
-	
-	public static function getURICollection($collectionURI) {
-		return self::getURIObject($collectionURI, 'collection');
-	}
-	
-	
-	public static function getURILibrary($libraryURI) {
-		return self::getURIObject($libraryURI, "library");
-	}
-	
-	
-	private static function getURIObject($objectURI, $type) {
-		$Types = ucwords($type) . 's';
-		$types = strtolower($Types);
-		
-		$libraryType = null;
-		
-		$baseURI = self::getBaseURI();
-		
-		// If not found, try global URI
-		if (strpos($objectURI, $baseURI) !== 0) {
-			throw new Exception("Invalid base URI '$objectURI'");
-		}
-		$objectURI = substr($objectURI, strlen($baseURI));
-		$typeRE = "/^(users|groups)\/([0-9]+)(?:\/|$)/";
-		if (!preg_match($typeRE, $objectURI, $matches)) {
-			throw new Exception("Invalid library URI '$objectURI'");
-		}
-		$libraryType = substr($matches[1], 0, -1);
-		$id = $matches[2];
-		$objectURI = preg_replace($typeRE, '', $objectURI);
-		
-		if ($libraryType == 'user') {
-			if (!Zotero_Users::exists($id)) {
-				return false;
-			}
-			$libraryID = Zotero_Users::getLibraryIDFromUserID($id);
-		}
-		else if ($libraryType == 'group') {
-			if (!Zotero_Groups::get($id)) {
-				return false;
-			}
-			$libraryID = Zotero_Groups::getLibraryIDFromGroupID($id);
-		}
-		else {
-			throw new Exception("Invalid library type $libraryType");
-		}
-		
-		if ($type === 'library') {
-			return $libraryID;
-		}
-		else {
-			// TODO: objectID-based URI?
-			if (!preg_match('/' . $types . "\/([A-Z0-9]{8})/", $objectURI, $matches)) {
-				throw new Exception("Invalid object URI '$objectURI'");
-			}
-			$objectKey = $matches[1];
-			return call_user_func(array("Zotero_$Types", "getByLibraryAndKey"), $libraryID, $objectKey);
-		}
-	}
-}
-?>
diff --git a/model/URL.inc.php b/model/URL.inc.php
deleted file mode 100644
index 9619f241..00000000
--- a/model/URL.inc.php
+++ /dev/null
@@ -1,54 +0,0 @@
-<?
-/*
-    Add license block if adding additional code
-*/
-
-class Zotero_URL {
-	/**
-	 * Handle multiple identical parameters in the CGI-standard way instead of
-	 * PHP's foo[]=bar way
-	 *
-	 * By Evan K on http://us.php.net/manual/en/function.parse-str.php
-	 */
-	public static function proper_parse_str($str) {
-		if (!$str) {
-			return array();
-		}
-		$arr = array();
-		
-		$pairs = explode('&', $str);
-		
-		foreach ($pairs as $i) {
-			// Skip if no equals sign
-			if (strpos($i, '=') === false) {
-				continue;
-			}
-			
-			list($name, $value) = explode('=', $i, 2);
-			
-			// Skip if empty value
-			if (!$value && $value !== '0') {
-				continue;
-			}
-			
-			// Added by Dan S.
-			$value = urldecode($value);
-			
-			// if name already exists
-			if (isset($arr[$name])) {
-				// stick multiple values into an array
-				if (is_array($arr[$name])) {
-					$arr[$name][] = $value;
-				}
-				else {
-					$arr[$name] = array($arr[$name], $value);
-				}
-			}
-			// otherwise, simply stick it in a scalar
-			else {
-				$arr[$name] = $value;
-			}
-		}
-		return $arr;
-	}
-}
diff --git a/model/Users.inc.php b/model/Users.inc.php
deleted file mode 100644
index 2c1c863e..00000000
--- a/model/Users.inc.php
+++ /dev/null
@@ -1,607 +0,0 @@
-<?
-/*
-    ***** 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_Users {
-	public static $userXMLHash = array();
-	
-	private static $usernamesByID = [];
-	private static $realNamesByID = [];
-	private static $userLibraryIDs = [];
-	private static $libraryUserIDs = [];
-	
-	
-	/**
-	 * Get the id of the library of the given type associated with the given user
-	 *
-	 * @param int $userID
-	 * @param string [$libraryType='user']
-	 * @throws Exception with code Z_ERROR_USER_NOT_FOUND if user library missing
-	 * @return int|false Library ID, or false if library not found (except user library,
-	 *     which throws)
-	 */
-	public static function getLibraryIDFromUserID($userID, $libraryType='user') {
-		if (isset(self::$userLibraryIDs[$libraryType][$userID])) {
-			return self::$userLibraryIDs[$libraryType][$userID];
-		}
-		$cacheKey = 'user' . ucwords($libraryType) . 'LibraryID_' . $userID;
-		$libraryID = Z_Core::$MC->get($cacheKey);
-		if ($libraryID) {
-			self::$userLibraryIDs[$libraryType][$libraryID] = $libraryID;
-			return $libraryID;
-		}
-		switch ($libraryType) {
-		case 'user':
-			$sql = "SELECT libraryID FROM users WHERE userID=?";
-			break;
-		
-		case 'publications':
-			$sql = "SELECT libraryID FROM userPublications WHERE userID=?";
-			break;
-		
-		case 'group':
-			throw new Exception("Can't get single group libraryID from userID");
-		}
-			
-		$libraryID = Zotero_DB::valueQuery($sql, $userID);
-		if (!$libraryID) {
-			if ($libraryType == 'publications') {
-				return false;
-			}
-			throw new Exception(ucwords($libraryType) . " library not found for user $userID",
-				Z_ERROR_USER_NOT_FOUND);
-		}
-		self::$userLibraryIDs[$libraryType][$userID] = $libraryID;
-		Z_Core::$MC->set($cacheKey, $libraryID);
-		return $libraryID;
-	}
-	
-	
-	public static function getUserIDFromLibraryID($libraryID, $libraryType=null) {
-		if (isset(self::$libraryUserIDs[$libraryID])) {
-			return self::$libraryUserIDs[$libraryID];
-		}
-		$cacheKey = 'libraryUserID_' . $libraryID;
-		$userID = Z_Core::$MC->get($cacheKey);
-		if ($userID) {
-			self::$libraryUserIDs[$libraryID] = $userID;
-			return $userID;
-		}
-		
-		if ($libraryType == null) {
-			$libraryType = Zotero_Libraries::getType($libraryID);
-		}
-		
-		switch ($libraryType) {
-		case 'user':
-			$sql = "SELECT userID FROM users WHERE libraryID=?";
-			break;
-		
-		case 'publications':
-			$sql = "SELECT userID FROM userPublications WHERE libraryID=?";
-			break;
-		
-		case 'group':
-			$sql = "SELECT userID FROM groupUsers JOIN groups USING (groupID) "
-				. "WHERE libraryID=? AND role='owner'";
-			break;
-		}
-		
-		$userID = Zotero_DB::valueQuery($sql, $libraryID);
-		if (!$userID) {
-			if (!Zotero_Libraries::exists($libraryID)) {
-				throw new Exception("Library $libraryID does not exist");
-			}
-			// Wrong library type passed
-			error_log("Wrong library type passed for library $libraryID "
-				. "in Zotero_Users::getUserIDFromLibraryID()");
-			return self::getUserIDFromLibraryID($libraryID);
-		}
-		self::$libraryUserIDs[$libraryID] = $userID;
-		Z_Core::$MC->set($cacheKey, $userID);
-		return $userID;
-	}
-	
-	
-	/**
-	 * Warning: This method may lie or return false..
-	 */
-	public static function getUserIDFromUsername($username) {
-		$sql = "SELECT userID FROM users WHERE username=?";
-		return Zotero_DB::valueQuery($sql, $username);
-	}
-	
-	
-	public static function getUserIDFromSessionID($sessionID) {
-		$sql = "SELECT userID FROM sessions WHERE id=?
-				AND UNIX_TIMESTAMP() < modified + lifetime";
-		try {
-			$userID = Zotero_WWW_DB_2::valueQuery($sql, $sessionID);
-			Zotero_WWW_DB_2::close();
-		}
-		catch (Exception $e) {
-			Z_Core::logError("WARNING: $e -- retrying on primary");
-			$userID = Zotero_WWW_DB_1::valueQuery($sql, $sessionID);
-			Zotero_WWW_DB_1::close();
-		}
-		
-		return $userID;
-	}
-	
-	
-	public static function getUsername($userID, $skipAutoAdd=false) {
-		if (!empty(self::$usernamesByID[$userID])) {
-			return self::$usernamesByID[$userID];
-		}
-		
-		$cacheKey = "usernameByID_" . $userID;
-		$username = Z_Core::$MC->get($cacheKey);
-		if ($username) {
-			self::$usernamesByID[$userID] = $username;
-			return $username;
-		}
-		
-		if (!$skipAutoAdd) {
-			if (!self::exists($userID)) {
-				self::addFromWWW($userID);
-			}
-			else {
-				self::updateFromWWW($userID);
-			}
-		}
-		
-		$sql = "SELECT username FROM users WHERE userID=?";
-		$username = Zotero_DB::valueQuery($sql, $userID);
-		if (!$username) {
-			throw new Exception("Username for userID $userID not found", Z_ERROR_USER_NOT_FOUND);
-		}
-		
-		self::$usernamesByID[$userID] = $username;
-		Z_Core::$MC->set($cacheKey, $username, 43200);
-		
-		return $username;
-	}
-	
-	
-	public static function getRealName($userID) {
-		if (!empty(self::$realNamesByID[$userID])) {
-			return self::$realNamesByID[$userID];
-		}
-		
-		$cacheKey = "userRealNameByID_" . $userID;
-		$name = Z_Core::$MC->get($cacheKey);
-		if ($name) {
-			self::$realNamesByID[$userID] = $name;
-			return $name;
-		}
-		
-		$sql = "SELECT metaValue FROM users_meta WHERE userID=? AND metaKey='profile_realname'";
-		$params = [$userID];
-		try {
-			$name = Zotero_WWW_DB_2::valueQuery($sql, $params);
-			Zotero_WWW_DB_2::close();
-		}
-		catch (Exception $e) {
-			try {
-				Z_Core::logError("WARNING: $e -- retrying on primary");
-				$name = Zotero_WWW_DB_1::valueQuery($sql, $params);
-				Zotero_WWW_DB_1::close();
-			}
-			catch (Exception $e2) {
-				Z_Core::logError("WARNING: " . $e2);
-			}
-		}
-		
-		if (!$name) {
-			return false;
-		}
-		
-		self::$realNamesByID[$userID] = $name;
-		Z_Core::$MC->set($cacheKey, $name);
-		
-		return $name;
-	}
-	
-	
-	public static function toJSON($userID) {
-		$realName = Zotero_Users::getRealName($userID);
-		$json = [
-			'id' => $userID,
-			'username' => Zotero_Users::getUsername($userID),
-			'name' => $realName !== false ? $realName : ""
-		];
-		$json['links'] = [
-			'alternate' => [
-				'href' => Zotero_URI::getUserURI($userID, true),
-				'type' => 'text/html'
-			]
-		];
-		
-		return $json;
-	}
-	
-	
-	public static function exists($userID) {
-		if (Z_Core::$MC->get('userExists_' . $userID)) {
-			return true;
-		}
-		$sql = "SELECT COUNT(*) FROM users WHERE userID=?";
-		$exists = Zotero_DB::valueQuery($sql, $userID);
-		if ($exists) {
-			Z_Core::$MC->set('userExists_' . $userID, 1, 86400);
-			return true;
-		}
-		return false;
-	}
-	
-	
-	public static function authenticate($method, $authData) {
-		return call_user_func(array('Zotero_AuthenticationPlugin_' . ucwords($method), 'authenticate'), $authData);
-	}
-	
-	
-	public static function add($userID, $username='') {
-		Zotero_DB::beginTransaction();
-		
-		$shardID = Zotero_Shards::getNextShard();
-		$libraryID = Zotero_Libraries::add('user', $shardID);
-		
-		$sql = "INSERT INTO users (userID, libraryID, username) VALUES (?, ?, ?)";
-		Zotero_DB::query($sql, array($userID, $libraryID, $username));
-		
-		Zotero_DB::commit();
-		
-		return $libraryID;
-	}
-	
-	
-	public static function addFromWWW($userID) {
-		if (self::exists($userID)) {
-			throw new Exception("User $userID already exists");
-		}
-		// Throws an error if user not found
-		$username = self::getUsernameFromWWW($userID);
-		self::add($userID, $username);
-	}
-	
-	
-	public static function updateFromWWW($userID) {
-		// Throws an error if user not found
-		$username = self::getUsernameFromWWW($userID);
-		self::updateUsername($userID, $username);
-	}
-	
-	
-	public static function update($userID, $username=false) {
-		$sql = "UPDATE users SET ";
-		$params = array();
-		if ($username) {
-			$sql .= "username=?, ";
-			$params[] = $username;
-		}
-		$sql .= "lastSyncTime=NOW() WHERE userID=?";
-		$params[] = $userID;
-		return Zotero_DB::query($sql, $params);
-	}
-	
-	
-	public static function updateUsername($userID, $username) {
-		$sql = "UPDATE users SET username=? WHERE userID=?";
-		return Zotero_DB::query(
-			$sql,
-			[
-				$username,
-				$userID
-			],
-			0,
-			[
-				'writeInReadMode' => true
-			]
-		);
-	}
-	
-	
-	/**
-	 * Get a key to represent the current state of all of a user's libraries
-	 */
-	public static function getUpdateKey($userID, $oldStyle=false) {
-		$libraryIDs = Zotero_Libraries::getUserLibraries($userID);
-		$parts = array();
-		foreach ($libraryIDs as $libraryID) {
-			if ($oldStyle) {
-				$sql = "SELECT UNIX_TIMESTAMP(lastUpdated) FROM shardLibraries WHERE libraryID=?";
-			}
-			else {
-				$sql = "SELECT version FROM shardLibraries WHERE libraryID=?";
-			}
-			$timestamp = Zotero_DB::valueQuery(
-				$sql, $libraryID, Zotero_Shards::getByLibraryID($libraryID)
-			);
-			$parts[] = $libraryID . ':' . $timestamp;
-		}
-		return md5(implode(',', $parts));
-	}
-	
-	
-	public static function getEarliestDataTimestamp($userID) {
-		$earliest = false;
-		
-		$libraryIDs = Zotero_Libraries::getUserLibraries($userID);
-		$shardIDs = Zotero_Shards::getUserShards($userID);
-		
-		foreach ($shardIDs as $shardID) {
-			$sql = '';
-			$params = array();
-			
-			foreach (Zotero_DataObjects::$classicObjectTypes as $type) {
-				$className = 'Zotero_' . $type['plural'];
-				// ClassicDataObjects
-				if (method_exists($className, "field")) {
-					$table = call_user_func([$className, 'field'], 'table');
-				}
-				else {
-					$table = $className::$table;
-				}
-				if ($table == 'relations') {
-					$field = 'serverDateModified';
-				}
-				else if ($table == 'settings') {
-					$field = 'lastUpdated';
-				}
-				else {
-					$field = 'dateModified';
-				}
-				
-				$sql .= "SELECT UNIX_TIMESTAMP($table.$field) AS time FROM $table
-						WHERE libraryID IN ("
-						. implode(', ', array_fill(0, sizeOf($libraryIDs), '?'))
-						. ") UNION ";
-				$params = array_merge($params, $libraryIDs);
-			}
-			
-			$sql = substr($sql, 0, -6) . " ORDER BY time ASC LIMIT 1";
-			$time = Zotero_DB::valueQuery($sql, $params, $shardID);
-			if ($time && (!$earliest || $time < $earliest)) {
-				$earliest = $time;
-			}
-		}
-		
-		return $earliest;
-	}
-	
-	
-	public static function getLastStorageSync($userID) {
-		$lastModified = false;
-		
-		$libraryIDs = Zotero_Libraries::getUserLibraries($userID);
-		$shardIDs = Zotero_Shards::getUserShards($userID);
-		
-		foreach ($shardIDs as $shardID) {
-			$sql = "SELECT UNIX_TIMESTAMP(serverDateModified) AS time FROM items
-					JOIN storageFileItems USING (itemID)
-					WHERE libraryID IN ("
-					. implode(', ', array_fill(0, sizeOf($libraryIDs), '?'))
-					. ")
-					ORDER BY time DESC LIMIT 1";
-			$time = Zotero_DB::valueQuery($sql, $libraryIDs, $shardID);
-			if ($time > $lastModified) {
-				$lastModified = $time;
-			}
-		}
-		
-		return $lastModified;
-	}
-	
-	
-	public static function isValidUser($userID) {
-		if (!$userID) {
-			throw new Exception("Invalid user");
-		}
-		
-		$cacheKey = "validUser_" . $userID;
-		$valid = Z_Core::$MC->get($cacheKey);
-		if ($valid === 1) {
-			return true;
-		}
-		else if ($valid === 0) {
-			return false;
-		}
-		
-		$valid = !!self::getValidUsersDB(array($userID));
-		
-		Z_Core::$MC->set($cacheKey, $valid ? 1 : 0, 300);
-		
-		return $valid;
-	}
-	
-	
-	public static function getValidUsers($userIDs) {
-		if (!$userIDs) {
-			return array();
-		}
-		
-		$newUserIDs = array();
-		foreach ($userIDs as $id) {
-			if (Zotero_Users::isValidUser($id)) {
-				$newUserIDs[] = $id;
-			}
-		}
-		
-		return $newUserIDs;
-	}
-	
-	
-	public static function getValidUsersDB($userIDs) {
-		if (!$userIDs) {
-			return array();
-		}
-		
-		$invalid = array();
-		
-		// Get any of these users that are known to be invalid
-		$sql = "SELECT UserID FROM GDN_User WHERE Banned=1 AND UserID IN ("
-			. implode(', ', array_fill(0, sizeOf($userIDs), '?'))
-			. ")";
-		
-		try {
-			$invalid = Zotero_WWW_DB_2::columnQuery($sql, $userIDs);
-			Zotero_WWW_DB_2::close();
-		}
-		catch (Exception $e) {
-			try {
-				Z_Core::logError("WARNING: $e -- retrying on primary");
-				$invalid = Zotero_WWW_DB_1::columnQuery($sql, $userIDs);
-				Zotero_WWW_DB_1::close();
-			}
-			catch (Exception $e2) {
-				Z_Core::logError("WARNING: " . $e2);
-				
-				// If not available, assume valid
-			}
-		}
-		
-		if ($invalid) {
-			$userIDs = array_diff($userIDs, $invalid);
-		}
-		
-		return $userIDs;
-	}
-	
-	
-	public static function clearAllData($userID) {
-		if (empty($userID)) {
-			throw new Exception("userID not provided");
-		}
-		
-		Zotero_DB::beginTransaction();
-		
-		$libraryID = self::getLibraryIDFromUserID($userID, 'publications');
-		if ($libraryID) {
-			Zotero_Libraries::clearAllData($libraryID);
-		}
-		
-		$libraryID = self::getLibraryIDFromUserID($userID);
-		Zotero_Libraries::clearAllData($libraryID);
-		
-		// TODO: Better handling of locked out sessions elsewhere
-		$sql = "UPDATE sessions SET timestamp='0000-00-00 00:00:00',
-					exclusive=0 WHERE userID=? AND exclusive=1";
-		Zotero_DB::query($sql, $userID);
-		
-		Zotero_DB::commit();
-	}
-	
-	
-	public static function deleteUser($userID) {
-		if (empty($userID)) {
-			throw new Exception("userID not provided");
-		}
-		
-		$username = Zotero_Users::getUsername($userID, true);
-		
-		$sql = "SELECT Deleted FROM GDN_User WHERE UserID=?";
-		try {
-			$deleted = Zotero_WWW_DB_2::valueQuery($sql, $userID);
-		}
-		catch (Exception $e) {
-			Z_Core::logError("WARNING: $e -- retrying on primary");
-			$deleted = Zotero_WWW_DB_1::valueQuery($sql, $userID);
-		}
-		if (!$deleted) {
-			throw new Exception("User '$username' has not been deleted in user table");
-		}
-		
-		Zotero_DB::beginTransaction();
-		
-		if (Zotero_Groups::getUserOwnedGroups($userID)) {
-			throw new Exception("Cannot delete user '$username' with owned groups");
-		}
-		
-		// Remove user from any groups they're a member of
-		//
-		// This isn't strictly necessary thanks to foreign key cascades,
-		// but it removes some extra keyPermissions rows
-		$groupIDs = Zotero_Groups::getUserGroups($userID);
-		foreach ($groupIDs as $groupID) {
-			$group = Zotero_Groups::get($groupID, true);
-			$group->removeUser($userID);
-		}
-		
-		// Remove all data
-		Zotero_Users::clearAllData($userID);
-		
-		// Remove user publications library
-		$libraryID = self::getLibraryIDFromUserID($userID, 'publications');
-		if ($libraryID) {
-			$shardID = Zotero_Shards::getByLibraryID($libraryID);
-			Zotero_DB::query("DELETE FROM shardLibraries WHERE libraryID=?", $libraryID, $shardID);
-			Zotero_DB::query("DELETE FROM libraries WHERE libraryID=?", $libraryID);
-		}
-		
-		// Remove user/library rows
-		$libraryID = self::getLibraryIDFromUserID($userID);
-		$shardID = Zotero_Shards::getByLibraryID($libraryID);
-		Zotero_DB::query("DELETE FROM shardLibraries WHERE libraryID=?", $libraryID, $shardID);
-		Zotero_DB::query("DELETE FROM libraries WHERE libraryID=?", $libraryID);
-		
-		Zotero_DB::commit();
-	}
-	
-	
-	public static function hasPublicationsInUserLibrary($userID) {
-		$sql = "SELECT COUNT(*) > 0 FROM publicationsItems JOIN items WHERE libraryID=?";
-		$libraryID = self::getLibraryIDFromUserID($userID);
-		$shardID = Zotero_Shards::getByLibraryID($libraryID);
-		return !!Zotero_DB::valueQuery($sql, $libraryID, $shardID);
-	}
-	
-	
-	public static function hasPublicationsInLegacyLibrary($userID) {
-		$libraryID = Zotero_Users::getLibraryIDFromUserID($userID, 'publications');
-		if (!$libraryID) {
-			return false;
-		}
-		$sql = "SELECT COUNT(*) > 0 FROM items WHERE libraryID=?";
-		$shardID = Zotero_Shards::getByLibraryID($libraryID);
-		return !!Zotero_DB::valueQuery($sql, $libraryID, $shardID);
-	}
-	
-	
-	private static function getUsernameFromWWW($userID) {
-		$sql = "SELECT username FROM users WHERE userID=?";
-		try {
-			$username = Zotero_WWW_DB_2::valueQuery($sql, $userID);
-		}
-		catch (Exception $e) {
-			Z_Core::logError("WARNING: $e -- retrying on primary");
-			$username = Zotero_WWW_DB_1::valueQuery($sql, $userID);
-		}
-		if (!$username) {
-			throw new Exception("User $userID not found", Z_ERROR_USER_NOT_FOUND);
-		}
-		return $username;
-	}
-}
-?>
diff --git a/model/Utilities.inc.php b/model/Utilities.inc.php
deleted file mode 100644
index 61331abd..00000000
--- a/model/Utilities.inc.php
+++ /dev/null
@@ -1,179 +0,0 @@
-<?
-/*
-    ***** 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_Utilities {
-	/**
-	 * Generates random string of given length
-	 *
-	 * @param int    $length
-	 * @param string [$mode='lower']              'key', 'lower', 'upper', 'mixed'
-	 * @param bool   [$exclude_ambiguous=false]   Exclude letters that are hard to distinguish visually
-	 **/
-	public static function randomString($length, $mode='lower', $exclude_ambiguous=false) {
-		// if you want extended ascii, then add the characters to the array
-		$upper = array('A','B','C','D','E','F','G','H','I','J','K','L','M','N','P','Q','R','S','T','U','V','W','X','Y','Z');
-		$lower = array('a','b','c','d','e','f','g','h','i','j','k','m','n','o','p','q','r','s','t','u','v','w','x','y','z');
-		$numbers = array('2','3','4','5','6','7','8','9');
-		
-		switch ($mode) {
-			// Special case for object ids, which don't use 'O'
-			// (and are inadvertently missing 'L' and 'Y')
-			case 'key':
-				$characters = array_merge(
-					array('A','B','C','D','E','F','G','H','I','J','K','M','N','P','Q','R','S','T','U','V','W','X','Z'),
-					$numbers
-				);
-				break;
-			
-			case 'mixed':
-				$characters = array_merge($lower, $upper, $numbers);
-				if (!$exclude_ambiguous){
-					$characters = array_merge($characters, array('l','1','0','O'));
-				}
-				break;
-			case 'upper':
-				$characters = array_merge($upper, $numbers);
-				if (!$exclude_ambiguous){
-					// This should include 'I', but the client uses it, so too late
-					$characters = array_merge($characters, array('1','0','O'));
-				}
-				break;
-			
-			case 'lower':
-			default:
-				$characters = array_merge($lower, $numbers);
-				if (!$exclude_ambiguous){
-					$characters = array_merge($characters, array('l','1','0'));
-				}
-				break;
-		}
-		
-		$random_str = "";
-		for ($i = 0; $i < $length; $i++) {
-			$random_str .= $characters[array_rand($characters)];
-		}
-		return $random_str;
-	}
-	
-	
-	public static function isPosInt($val) {
-		// From http://us.php.net/manual/en/function.is-int.php#93560
-		return ctype_digit((string) $val);
-	}
-	
-	
-    /**
-     * Generate url friendly slug from name
-     *
-     * @param string $input name to generate slug from
-     * @return string
-     */
-    public static function slugify($input) {
-        $slug = trim($input);
-        $slug = strtolower($slug);
-        $slug = preg_replace("/[^a-z0-9 ._-]/", "", $slug);
-        $slug = str_replace(" ", "_", $slug);
-        return $slug;
-    }
-    
-    
-	public static function ellipsize($str, $len) {
-		if (!$len) {
-			throw new Exception("Length not specified");
-		}
-		if (mb_strlen($str) > $len) {
-			return mb_substr($str, 0, $len) . '…';
-		}
-		return $str;
-	}
-    
-	
-	public static function formatJSON($jsonObj) {
-		$mask = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT;
-		return json_encode($jsonObj, $mask);
-	}
-	
-	
-	/**
-	 * Strip control characters from string
-	 */
-	public static function cleanString($str) {
-		$chars = array();
-		for ($i = 0; $i < 32; $i++) {
-			// Don't strip line feed and tab
-			if ($i != 9 && $i != 10) {
-				$chars[] = chr($i);
-			}
-		}
-		$chars[] = chr(127);
-		return str_replace($chars, '', $str);
-	}
-	
-	
-	/**
-	 * Recursively call cleanString() on an object's scalar properties
-	 */
-	public static function cleanStringRecursive($obj) {
-		foreach ($obj as &$val) {
-			if (is_scalar($val) || $val === null) {
-				if (is_string($val)) {
-					$val = self::cleanString($val);
-				}
-			}
-			else {
-				self::{__FUNCTION__}($val);
-			}
-		}
-	}
-	
-	
-	/**
-	 * JavaScript-equivalent trim, which strips all Unicode whitespace
-	 */
-	public static function unicodeTrim($str) {
-		return preg_replace('/^[\pZ\pC]+|[\pZ\pC]+$/u','', $str);
-	}
-	
-	
-	/**
-	 * Much faster implementation of array_diff, but limited to
-	 * comparing two arrays of integers or strings
-	 *
-	 * From http://php.net/array_diff#107928
-	 *
-	 * @return {Array}  Values from array1 that aren't in array2
-	 */
-	public static function arrayDiffFast($arrayFrom, $arrayAgainst) {
-		$arrayAgainst = array_flip($arrayAgainst);
-		foreach ($arrayFrom as $key => $value) {
-			if (isset($arrayAgainst[$value])) {
-				unset($arrayFrom[$key]);
-			}
-		}
-		return $arrayFrom;
-	}
-}
-?>
diff --git a/model/auth/Password.inc.php b/model/auth/Password.inc.php
deleted file mode 100644
index b405f39a..00000000
--- a/model/auth/Password.inc.php
+++ /dev/null
@@ -1,126 +0,0 @@
-<?
-/*
-    ***** 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_AuthenticationPlugin_Password implements Zotero_AuthenticationPlugin {
-	public static function authenticate($data) {
-		$salt = Z_CONFIG::$AUTH_SALT;
-		
-		// TODO: config
-		$dev = Z_ENV_TESTING_SITE ? "_test" : "";
-		$databaseName = "zotero_www{$dev}";
-		
-		$username = $data['username'];
-		$password = $data['password'];
-		$isEmailAddress = strpos($username, '@') !== false;
-		
-		$cacheKey = 'userAuthHash_' . hash('sha256', $username . $password);
-		$userID = Z_Core::$MC->get($cacheKey);
-		if ($userID) {
-			return $userID;
-		}
-		
-		// Username
-		if (!$isEmailAddress) {
-			$sql = "SELECT userID, username, password AS hash FROM $databaseName.users WHERE username=?";
-			$params = [$username];
-		}
-		else {
-			$sql = "SELECT userID, username, password AS hash FROM $databaseName.users
-			   WHERE username = ?
-			   UNION
-			   SELECT userID, username, password AS hash FROM $databaseName.users
-			   WHERE email = ?
-			   ORDER BY username = ? DESC";
-			$params = [$username, $username, $username];
-		}
-		
-		try {
-			$retry = true;
-			$rows = Zotero_WWW_DB_2::query($sql, $params);
-			Zotero_WWW_DB_2::close();
-			if (!$rows) {
-				$retry = false;
-				$rows = Zotero_WWW_DB_1::query($sql, $params);
-				Zotero_WWW_DB_1::close();
-			}
-		}
-		catch (Exception $e) {
-			if ($retry) {
-				Z_Core::logError("WARNING: $e -- retrying on primary");
-				$rows = Zotero_WWW_DB_1::query($sql, $params);
-				Zotero_WWW_DB_1::close();
-			}
-		}
-		
-		if (!$rows) {
-			return false;
-		}
-		
-		$found = false;
-		foreach ($rows as $row) {
-			// Try bcrypt
-			$found = password_verify($password, $row['hash']);
-			
-			// Try salted SHA1
-			if (!$found) {
-				$found = sha1($salt . $password) == $row['hash'];
-			}
-			
-			// Try MD5
-			if (!$found) {
-				$found = md5($password) == $row['hash'];
-			}
-			
-			if ($found) {
-				$foundRow = $row;
-				break;
-			}
-		}
-		
-		if (!$found) {
-			return false;
-		}
-		
-		self::updateUser($foundRow['userID'], $foundRow['username']);
-		Z_Core::$MC->set($cacheKey, $foundRow['userID'], 60);
-		return $foundRow['userID'];
-	}
-	
-	
-	private static function updateUser($userID, $username) {
-		if (Zotero_Users::exists($userID)) {
-			$currentUsername = Zotero_Users::getUsername($userID, true);
-			if ($currentUsername != $username) {
-				Zotero_Users::update($userID, $username);
-			}
-		}
-		else {
-			Zotero_Users::add($userID, $username);
-			Zotero_Users::update($userID);
-		}
-	}
-}
-?>
diff --git a/model/relax-ng/data9.rnc b/model/relax-ng/data9.rnc
deleted file mode 100644
index cd18cdbf..00000000
--- a/model/relax-ng/data9.rnc
+++ /dev/null
@@ -1,239 +0,0 @@
-libraryID = attribute libraryID { xsd:integer }
-key = attribute key { keyPattern }
-keyPattern = xsd:string { pattern = "[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}" }
-md5Pattern = xsd:string { pattern = "[abcdefg0-9]{32}" }
-keys = list { keyPattern+ }
-dateAdded = attribute dateAdded { xsd:string { pattern = "\-?[0-9]{4}\-(0[1-9]|10|11|12)\-(0[1-9]|[1-2][0-9]|30|31) ([0-1][0-9]|[2][0-3]):([0-5][0-9]):([0-5][0-9])" } }
-dateModified = attribute dateModified { xsd:string { pattern = "\-?[0-9]{4}\-(0[1-9]|10|11|12)\-(0[1-9]|[1-2][0-9]|30|31) ([0-1][0-9]|[2][0-3]):([0-5][0-9]):([0-5][0-9])" } }
-index = attribute index { xsd:integer }
-field = element field { attribute name { text }, text }
-id = attribute id { xsd:integer }
-
-start =
-	element items {
-		element item {
-			(
-				key
-			|
-				(
-					libraryID,
-					key,
-					attribute createdByUserID { xsd:integer }?,
-					attribute lastModifiedByUserID { xsd:integer }?
-				)
-			),
-			dateAdded,
-			dateModified,
-			attribute deleted { xsd:boolean }?,
-			(
-				(
-					attribute itemType { text },
-					(
-						field*,
-						element creator {
-							libraryID?,
-							key,
-							attribute creatorType { text },
-							index,
-							element creator {
-								libraryID?,
-								key,
-								dateAdded,
-								dateModified,
-								(
-									(
-										element name { text },
-										element fieldMode { "1" }
-									)
-										|
-									(
-										element firstName { text },
-										element lastName { text },
-										element fieldMode { "0" }?
-									)
-								),
-								element birthYear { xsd:integer }?
-							}?
-						}*
-					)
-				)
-					|
-				(
-					attribute itemType { "note" },
-					attribute sourceItem { keyPattern | "undefined" }?,
-					element note { text }
-				)
-					|
-				(
-					attribute itemType { "attachment" },
-					attribute sourceItem { keyPattern | "undefined" }?,
-					attribute mimeType { text },
-					attribute charset { text }?,
-					(
-						(
-							(
-								attribute linkMode { "0" | "1" | "2" },
-								attribute storageModTime { xsd:integer }?,
-								attribute storageHash { md5Pattern }?,
-								field*,
-								element path { text }
-							)
-								|
-							(
-								attribute linkMode { "3" },
-								field*
-							)
-						),
-						element note { text }?
-					)
-				)
-			),
-			element related {
-				keys
-			}?
-		}*
-	}?
-	& element creators {
-		element creator {
-			libraryID?,
-			key,
-			dateAdded,
-			dateModified,
-			(
-				(
-					element name { text },
-					element fieldMode { "1" }
-				)
-					|
-				(
-					element firstName { text },
-					element lastName { text },
-					element fieldMode { "0" }?
-				)
-			),
-			element birthYear { xsd:integer }?
-		}*
-	}?
-	& element collections {
-		element collection {
-			libraryID?,
-			key,
-			attribute name { text },
-			dateAdded,
-			dateModified,
-			attribute parent { keyPattern }?,
-			element items {
-				keys
-			}?
-		}+
-	}?
-	& element searches {
-		element search {
-			libraryID?,
-			key,
-			attribute name { text },
-			dateAdded,
-			dateModified,
-			element condition {
-				id,
-				attribute condition { text },
-				attribute mode { text }?,
-				attribute operator { text },
-				attribute value { text },
-				attribute required { "0" | "1" }?
-			}*
-		}+
-	}?
-	& element tags {
-		element tag {
-			libraryID?,
-			key,
-			attribute name { text },
-			attribute type { xsd:integer }?,
-			dateAdded,
-			dateModified,
-			element items {
-				keys?
-			}?
-		}+
-	}?
-	& element groups {
-		element group {
-			libraryID?,
-			id,
-			attribute name { text },
-			attribute editable { "0" | "1" },
-			attribute filesEditable { "0" | "1" },
-			element description { text }?,
-			element url { xsd:anyURI }?
-		}+
-	}?
-	& element relations {
-		element relation {
-			libraryID?,
-			element subject { xsd:anyURI },
-			element predicate { xsd:anyURI },
-			element object { xsd:anyURI }
-		}+
-	}?
-	& element settings {
-		element setting {
-			libraryID,
-			attribute name { text },
-			attribute version { xsd:integer }?,
-			text
-		}+
-	}?
-	& element fulltexts {
-		element fulltext {
-			libraryID,
-			key,
-			attribute indexedChars { xsd:integer },
-			attribute totalChars { xsd:integer },
-			attribute indexedPages { xsd:integer },
-			attribute totalPages { xsd:integer },
-			attribute version { xsd:integer }?,
-			text
-		}+
-	}?
-	& element deleted {
-		element items {
-			element item {
-				libraryID?, key
-			}+
-		}? &
-		element creators {
-			element creator {
-				libraryID?, key
-			}+
-		}? &
-		element collections {
-			element collection {
-				libraryID?, key
-			}+
-		}? &
-		element searches {
-			element search {
-				libraryID?, key
-			}+
-		}? &
-		element tags {
-			element tag {
-				libraryID?, key
-			}+
-		}? &
-		element groups {
-			list { xsd:integer+ }
-		}? &
-		element relations {
-			element relation {
-				libraryID?,
-				attribute key {md5Pattern }
-			}+
-		}? &
-		element settings {
-			element setting {
-				libraryID, attribute key { text }
-			}+
-		}?
-	}?
diff --git a/model/relax-ng/data9.rng b/model/relax-ng/data9.rng
deleted file mode 100644
index bd2d5a27..00000000
--- a/model/relax-ng/data9.rng
+++ /dev/null
@@ -1,560 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<grammar xmlns="http://relaxng.org/ns/structure/1.0" datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
-  <define name="libraryID">
-    <attribute name="libraryID">
-      <data type="integer"/>
-    </attribute>
-  </define>
-  <define name="key">
-    <attribute name="key">
-      <ref name="keyPattern"/>
-    </attribute>
-  </define>
-  <define name="keyPattern">
-    <data type="string">
-      <param name="pattern">[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}</param>
-    </data>
-  </define>
-  <define name="md5Pattern">
-    <data type="string">
-      <param name="pattern">[abcdefg0-9]{32}</param>
-    </data>
-  </define>
-  <define name="keys">
-    <list>
-      <oneOrMore>
-        <ref name="keyPattern"/>
-      </oneOrMore>
-    </list>
-  </define>
-  <define name="dateAdded">
-    <attribute name="dateAdded">
-      <data type="string">
-        <param name="pattern">\-?[0-9]{4}\-(0[1-9]|10|11|12)\-(0[1-9]|[1-2][0-9]|30|31) ([0-1][0-9]|[2][0-3]):([0-5][0-9]):([0-5][0-9])</param>
-      </data>
-    </attribute>
-  </define>
-  <define name="dateModified">
-    <attribute name="dateModified">
-      <data type="string">
-        <param name="pattern">\-?[0-9]{4}\-(0[1-9]|10|11|12)\-(0[1-9]|[1-2][0-9]|30|31) ([0-1][0-9]|[2][0-3]):([0-5][0-9]):([0-5][0-9])</param>
-      </data>
-    </attribute>
-  </define>
-  <define name="index">
-    <attribute name="index">
-      <data type="integer"/>
-    </attribute>
-  </define>
-  <define name="field">
-    <element name="field">
-      <attribute name="name"/>
-      <text/>
-    </element>
-  </define>
-  <define name="id">
-    <attribute name="id">
-      <data type="integer"/>
-    </attribute>
-  </define>
-  <start>
-    <interleave>
-      <optional>
-        <element name="items">
-          <zeroOrMore>
-            <element name="item">
-              <choice>
-                <ref name="key"/>
-                <group>
-                  <ref name="libraryID"/>
-                  <ref name="key"/>
-                  <optional>
-                    <attribute name="createdByUserID">
-                      <data type="integer"/>
-                    </attribute>
-                  </optional>
-                  <optional>
-                    <attribute name="lastModifiedByUserID">
-                      <data type="integer"/>
-                    </attribute>
-                  </optional>
-                </group>
-              </choice>
-              <ref name="dateAdded"/>
-              <ref name="dateModified"/>
-              <optional>
-                <attribute name="deleted">
-                  <data type="boolean"/>
-                </attribute>
-              </optional>
-              <choice>
-                <group>
-                  <attribute name="itemType"/>
-                  <group>
-                    <zeroOrMore>
-                      <ref name="field"/>
-                    </zeroOrMore>
-                    <zeroOrMore>
-                      <element name="creator">
-                        <optional>
-                          <ref name="libraryID"/>
-                        </optional>
-                        <ref name="key"/>
-                        <attribute name="creatorType"/>
-                        <ref name="index"/>
-                        <optional>
-                          <element name="creator">
-                            <optional>
-                              <ref name="libraryID"/>
-                            </optional>
-                            <ref name="key"/>
-                            <ref name="dateAdded"/>
-                            <ref name="dateModified"/>
-                            <choice>
-                              <group>
-                                <element name="name">
-                                  <text/>
-                                </element>
-                                <element name="fieldMode">
-                                  <value>1</value>
-                                </element>
-                              </group>
-                              <group>
-                                <element name="firstName">
-                                  <text/>
-                                </element>
-                                <element name="lastName">
-                                  <text/>
-                                </element>
-                                <optional>
-                                  <element name="fieldMode">
-                                    <value>0</value>
-                                  </element>
-                                </optional>
-                              </group>
-                            </choice>
-                            <optional>
-                              <element name="birthYear">
-                                <data type="integer"/>
-                              </element>
-                            </optional>
-                          </element>
-                        </optional>
-                      </element>
-                    </zeroOrMore>
-                  </group>
-                </group>
-                <group>
-                  <attribute name="itemType">
-                    <value>note</value>
-                  </attribute>
-                  <optional>
-                    <attribute name="sourceItem">
-                      <choice>
-                        <ref name="keyPattern"/>
-                        <value>undefined</value>
-                      </choice>
-                    </attribute>
-                  </optional>
-                  <element name="note">
-                    <text/>
-                  </element>
-                </group>
-                <group>
-                  <attribute name="itemType">
-                    <value>attachment</value>
-                  </attribute>
-                  <optional>
-                    <attribute name="sourceItem">
-                      <choice>
-                        <ref name="keyPattern"/>
-                        <value>undefined</value>
-                      </choice>
-                    </attribute>
-                  </optional>
-                  <attribute name="mimeType"/>
-                  <optional>
-                    <attribute name="charset"/>
-                  </optional>
-                  <group>
-                    <choice>
-                      <group>
-                        <attribute name="linkMode">
-                          <choice>
-                            <value>0</value>
-                            <value>1</value>
-                            <value>2</value>
-                          </choice>
-                        </attribute>
-                        <optional>
-                          <attribute name="storageModTime">
-                            <data type="integer"/>
-                          </attribute>
-                        </optional>
-                        <optional>
-                          <attribute name="storageHash">
-                            <ref name="md5Pattern"/>
-                          </attribute>
-                        </optional>
-                        <zeroOrMore>
-                          <ref name="field"/>
-                        </zeroOrMore>
-                        <element name="path">
-                          <text/>
-                        </element>
-                      </group>
-                      <group>
-                        <attribute name="linkMode">
-                          <value>3</value>
-                        </attribute>
-                        <zeroOrMore>
-                          <ref name="field"/>
-                        </zeroOrMore>
-                      </group>
-                    </choice>
-                    <optional>
-                      <element name="note">
-                        <text/>
-                      </element>
-                    </optional>
-                  </group>
-                </group>
-              </choice>
-              <optional>
-                <element name="related">
-                  <ref name="keys"/>
-                </element>
-              </optional>
-            </element>
-          </zeroOrMore>
-        </element>
-      </optional>
-      <optional>
-        <element name="creators">
-          <zeroOrMore>
-            <element name="creator">
-              <optional>
-                <ref name="libraryID"/>
-              </optional>
-              <ref name="key"/>
-              <ref name="dateAdded"/>
-              <ref name="dateModified"/>
-              <choice>
-                <group>
-                  <element name="name">
-                    <text/>
-                  </element>
-                  <element name="fieldMode">
-                    <value>1</value>
-                  </element>
-                </group>
-                <group>
-                  <element name="firstName">
-                    <text/>
-                  </element>
-                  <element name="lastName">
-                    <text/>
-                  </element>
-                  <optional>
-                    <element name="fieldMode">
-                      <value>0</value>
-                    </element>
-                  </optional>
-                </group>
-              </choice>
-              <optional>
-                <element name="birthYear">
-                  <data type="integer"/>
-                </element>
-              </optional>
-            </element>
-          </zeroOrMore>
-        </element>
-      </optional>
-      <optional>
-        <element name="collections">
-          <oneOrMore>
-            <element name="collection">
-              <optional>
-                <ref name="libraryID"/>
-              </optional>
-              <ref name="key"/>
-              <attribute name="name"/>
-              <ref name="dateAdded"/>
-              <ref name="dateModified"/>
-              <optional>
-                <attribute name="parent">
-                  <ref name="keyPattern"/>
-                </attribute>
-              </optional>
-              <optional>
-                <element name="items">
-                  <ref name="keys"/>
-                </element>
-              </optional>
-            </element>
-          </oneOrMore>
-        </element>
-      </optional>
-      <optional>
-        <element name="searches">
-          <oneOrMore>
-            <element name="search">
-              <optional>
-                <ref name="libraryID"/>
-              </optional>
-              <ref name="key"/>
-              <attribute name="name"/>
-              <ref name="dateAdded"/>
-              <ref name="dateModified"/>
-              <zeroOrMore>
-                <element name="condition">
-                  <ref name="id"/>
-                  <attribute name="condition"/>
-                  <optional>
-                    <attribute name="mode"/>
-                  </optional>
-                  <attribute name="operator"/>
-                  <attribute name="value"/>
-                  <optional>
-                    <attribute name="required">
-                      <choice>
-                        <value>0</value>
-                        <value>1</value>
-                      </choice>
-                    </attribute>
-                  </optional>
-                </element>
-              </zeroOrMore>
-            </element>
-          </oneOrMore>
-        </element>
-      </optional>
-      <optional>
-        <element name="tags">
-          <oneOrMore>
-            <element name="tag">
-              <optional>
-                <ref name="libraryID"/>
-              </optional>
-              <ref name="key"/>
-              <attribute name="name"/>
-              <optional>
-                <attribute name="type">
-                  <data type="integer"/>
-                </attribute>
-              </optional>
-              <ref name="dateAdded"/>
-              <ref name="dateModified"/>
-              <optional>
-                <element name="items">
-                  <optional>
-                    <ref name="keys"/>
-                  </optional>
-                </element>
-              </optional>
-            </element>
-          </oneOrMore>
-        </element>
-      </optional>
-      <optional>
-        <element name="groups">
-          <oneOrMore>
-            <element name="group">
-              <optional>
-                <ref name="libraryID"/>
-              </optional>
-              <ref name="id"/>
-              <attribute name="name"/>
-              <attribute name="editable">
-                <choice>
-                  <value>0</value>
-                  <value>1</value>
-                </choice>
-              </attribute>
-              <attribute name="filesEditable">
-                <choice>
-                  <value>0</value>
-                  <value>1</value>
-                </choice>
-              </attribute>
-              <optional>
-                <element name="description">
-                  <text/>
-                </element>
-              </optional>
-              <optional>
-                <element name="url">
-                  <data type="anyURI"/>
-                </element>
-              </optional>
-            </element>
-          </oneOrMore>
-        </element>
-      </optional>
-      <optional>
-        <element name="relations">
-          <oneOrMore>
-            <element name="relation">
-              <optional>
-                <ref name="libraryID"/>
-              </optional>
-              <element name="subject">
-                <data type="anyURI"/>
-              </element>
-              <element name="predicate">
-                <data type="anyURI"/>
-              </element>
-              <element name="object">
-                <data type="anyURI"/>
-              </element>
-            </element>
-          </oneOrMore>
-        </element>
-      </optional>
-      <optional>
-        <element name="settings">
-          <oneOrMore>
-            <element name="setting">
-              <ref name="libraryID"/>
-              <attribute name="name"/>
-              <optional>
-                <attribute name="version">
-                  <data type="integer"/>
-                </attribute>
-              </optional>
-              <text/>
-            </element>
-          </oneOrMore>
-        </element>
-      </optional>
-      <optional>
-        <element name="fulltexts">
-          <oneOrMore>
-            <element name="fulltext">
-              <ref name="libraryID"/>
-              <ref name="key"/>
-              <attribute name="indexedChars">
-                <data type="integer"/>
-              </attribute>
-              <attribute name="totalChars">
-                <data type="integer"/>
-              </attribute>
-              <attribute name="indexedPages">
-                <data type="integer"/>
-              </attribute>
-              <attribute name="totalPages">
-                <data type="integer"/>
-              </attribute>
-              <optional>
-                <attribute name="version">
-                  <data type="integer"/>
-                </attribute>
-              </optional>
-              <text/>
-            </element>
-          </oneOrMore>
-        </element>
-      </optional>
-      <optional>
-        <element name="deleted">
-          <interleave>
-            <optional>
-              <element name="items">
-                <oneOrMore>
-                  <element name="item">
-                    <optional>
-                      <ref name="libraryID"/>
-                    </optional>
-                    <ref name="key"/>
-                  </element>
-                </oneOrMore>
-              </element>
-            </optional>
-            <optional>
-              <element name="creators">
-                <oneOrMore>
-                  <element name="creator">
-                    <optional>
-                      <ref name="libraryID"/>
-                    </optional>
-                    <ref name="key"/>
-                  </element>
-                </oneOrMore>
-              </element>
-            </optional>
-            <optional>
-              <element name="collections">
-                <oneOrMore>
-                  <element name="collection">
-                    <optional>
-                      <ref name="libraryID"/>
-                    </optional>
-                    <ref name="key"/>
-                  </element>
-                </oneOrMore>
-              </element>
-            </optional>
-            <optional>
-              <element name="searches">
-                <oneOrMore>
-                  <element name="search">
-                    <optional>
-                      <ref name="libraryID"/>
-                    </optional>
-                    <ref name="key"/>
-                  </element>
-                </oneOrMore>
-              </element>
-            </optional>
-            <optional>
-              <element name="tags">
-                <oneOrMore>
-                  <element name="tag">
-                    <optional>
-                      <ref name="libraryID"/>
-                    </optional>
-                    <ref name="key"/>
-                  </element>
-                </oneOrMore>
-              </element>
-            </optional>
-            <optional>
-              <element name="groups">
-                <list>
-                  <oneOrMore>
-                    <data type="integer"/>
-                  </oneOrMore>
-                </list>
-              </element>
-            </optional>
-            <optional>
-              <element name="relations">
-                <oneOrMore>
-                  <element name="relation">
-                    <optional>
-                      <ref name="libraryID"/>
-                    </optional>
-                    <attribute name="key">
-                      <ref name="md5Pattern"/>
-                    </attribute>
-                  </element>
-                </oneOrMore>
-              </element>
-            </optional>
-            <optional>
-              <element name="settings">
-                <oneOrMore>
-                  <element name="setting">
-                    <ref name="libraryID"/>
-                    <attribute name="key"/>
-                  </element>
-                </oneOrMore>
-              </element>
-            </optional>
-          </interleave>
-        </element>
-      </optional>
-    </interleave>
-  </start>
-</grammar>
diff --git a/model/relax-ng/updated.rnc b/model/relax-ng/updated.rnc
deleted file mode 100644
index 2fe965b5..00000000
--- a/model/relax-ng/updated.rnc
+++ /dev/null
@@ -1,13 +0,0 @@
-start = element response {
-	attribute timestamp { xsd:decimal  },
-	(
-		(
-			attribute version { "9" },
-			element updated { external "data9.rnc" }
-		)
-	),
-	attribute userID { xsd:integer },
-	attribute defaultLibraryID { xsd:integer },
-	attribute updateKey { xsd:string { pattern = "[abcdefg0-9]{32}" } },
-	attribute earliest { xsd:decimal }
-}
diff --git a/model/relax-ng/updated.rng b/model/relax-ng/updated.rng
deleted file mode 100644
index 67c56c35..00000000
--- a/model/relax-ng/updated.rng
+++ /dev/null
@@ -1,32 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<grammar xmlns="http://relaxng.org/ns/structure/1.0" datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
-  <start>
-    <element name="response">
-      <attribute name="timestamp">
-        <data type="decimal"/>
-      </attribute>
-      <group>
-        <attribute name="version">
-          <value>9</value>
-        </attribute>
-        <element name="updated">
-          <externalRef href="data9.rng"/>
-        </element>
-      </group>
-      <attribute name="userID">
-        <data type="integer"/>
-      </attribute>
-      <attribute name="defaultLibraryID">
-        <data type="integer"/>
-      </attribute>
-      <attribute name="updateKey">
-        <data type="string">
-          <param name="pattern">[abcdefg0-9]{32}</param>
-        </data>
-      </attribute>
-      <attribute name="earliest">
-        <data type="decimal"/>
-      </attribute>
-    </element>
-  </start>
-</grammar>
diff --git a/model/relax-ng/upload.rnc b/model/relax-ng/upload.rnc
deleted file mode 100644
index d8daa0aa..00000000
--- a/model/relax-ng/upload.rnc
+++ /dev/null
@@ -1,6 +0,0 @@
-start = element data {
-	(
-		attribute version { "9" },
-		external "data9.rnc"
-	)
-}
diff --git a/model/relax-ng/upload.rng b/model/relax-ng/upload.rng
deleted file mode 100644
index dff8070a..00000000
--- a/model/relax-ng/upload.rng
+++ /dev/null
@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<grammar xmlns="http://relaxng.org/ns/structure/1.0">
-  <start>
-    <element name="data">
-      <attribute name="version">
-        <value>9</value>
-      </attribute>
-      <externalRef href="data9.rng"/>
-    </element>
-  </start>
-</grammar>
diff --git a/pa.Dockerfile b/pa.Dockerfile
new file mode 100644
index 00000000..866e95a8
--- /dev/null
+++ b/pa.Dockerfile
@@ -0,0 +1,6 @@
+
+############################
+# phpmyadmin image
+############################
+
+FROM phpmyadmin/phpmyadmin
\ No newline at end of file
diff --git a/pa.Dockerfile.dockerignore b/pa.Dockerfile.dockerignore
new file mode 100644
index 00000000..44d4ecf7
--- /dev/null
+++ b/pa.Dockerfile.dockerignore
@@ -0,0 +1,20 @@
+**/secret.json
+**/secret.txt
+**/secret.yaml
+.git
+.github
+.env
+.vscode/
+bin
+build
+client
+dataserver
+doc
+docker
+docker-compose.yml
+logs
+stream-server
+tinymce-clean-server
+Zend
+zotprime-k8s
+
diff --git a/processor/download/daemon.php b/processor/download/daemon.php
deleted file mode 100644
index b1b44187..00000000
--- a/processor/download/daemon.php
+++ /dev/null
@@ -1,15 +0,0 @@
-<?
-if (file_exists('../config')) {
-	include('../config');
-}
-if (file_exists('./config')) {
-	include('./config');
-}
-
-set_include_path("../../include");
-require("header.inc.php");
-require("../../model/ProcessorDaemon.inc.php");
-
-$daemon = new Zotero_Download_Processor_Daemon(!empty($daemonConfig) ? $daemonConfig : array());
-$daemon->run();
-?>
diff --git a/processor/download/processor.php b/processor/download/processor.php
deleted file mode 100644
index 915bc97f..00000000
--- a/processor/download/processor.php
+++ /dev/null
@@ -1,21 +0,0 @@
-<?
-error_reporting(E_ALL | E_STRICT);
-set_time_limit(900);
-
-if (file_exists('../config')) {
-	include('../config');
-}
-if (file_exists('./config')) {
-	include('./config');
-}
-
-set_include_path("../../include");
-require("header.inc.php");
-require('../../model/Error.inc.php');
-require('../../model/Processor.inc.php');
-
-
-$id = isset($argv[1]) ? $argv[1] : null;
-$processor = new Zotero_Download_Processor();
-$processor->run($id);
-?>
diff --git a/processor/error/daemon.php b/processor/error/daemon.php
deleted file mode 100644
index dfa42213..00000000
--- a/processor/error/daemon.php
+++ /dev/null
@@ -1,15 +0,0 @@
-<?
-if (file_exists('../config')) {
-	include('../config');
-}
-if (file_exists('./config')) {
-	include('./config');
-}
-
-set_include_path("../../include");
-require("header.inc.php");
-require("../../model/ProcessorDaemon.inc.php");
-
-$daemon = new Zotero_Error_Processor_Daemon(!empty($daemonConfig) ? $daemonConfig : array());
-$daemon->run();
-?>
diff --git a/processor/error/processor.php b/processor/error/processor.php
deleted file mode 100644
index b658aa17..00000000
--- a/processor/error/processor.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<?
-error_reporting(E_ALL | E_STRICT);
-set_time_limit(900);
-
-if (file_exists('../config')) {
-	include('../config');
-}
-if (file_exists('./config')) {
-	include('./config');
-}
-
-set_include_path("../../include");
-require("header.inc.php");
-require('../../model/Error.inc.php');
-require('../../model/Processor.inc.php');
-
-$id = isset($argv[1]) ? $argv[1] : null;
-$processor = new Zotero_Error_Processor();
-$processor->run($id);
-?>
diff --git a/processor/upload/daemon.php b/processor/upload/daemon.php
deleted file mode 100644
index 36e67681..00000000
--- a/processor/upload/daemon.php
+++ /dev/null
@@ -1,16 +0,0 @@
-<?
-if (file_exists('../config')) {
-	include('../config');
-}
-if (file_exists('./config')) {
-	include('./config');
-}
-
-set_include_path("../../include");
-require("header.inc.php");
-require("../../model/ProcessorDaemon.inc.php");
-
-
-$daemon = new Zotero_Upload_Processor_Daemon(!empty($daemonConfig) ? $daemonConfig : array());
-$daemon->run();
-?>
diff --git a/processor/upload/processor.php b/processor/upload/processor.php
deleted file mode 100644
index 288f6325..00000000
--- a/processor/upload/processor.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<?
-error_reporting(E_ALL | E_STRICT);
-set_time_limit(600);
-
-if (file_exists('../config')) {
-	include('../config');
-}
-if (file_exists('./config')) {
-	include('./config');
-}
-
-set_include_path("../../include");
-require("header.inc.php");
-require('../../model/Error.inc.php');
-require('../../model/Processor.inc.php');
-
-$id = isset($argv[1]) ? $argv[1] : null;
-$processor = new Zotero_Upload_Processor();
-$processor->run($id);
-?>
diff --git a/r.Dockerfile b/r.Dockerfile
new file mode 100644
index 00000000..641c8a32
--- /dev/null
+++ b/r.Dockerfile
@@ -0,0 +1,6 @@
+
+############################
+# redis image
+############################
+
+FROM redis:5.0
\ No newline at end of file
diff --git a/r.Dockerfile.dockerignore b/r.Dockerfile.dockerignore
new file mode 100644
index 00000000..44d4ecf7
--- /dev/null
+++ b/r.Dockerfile.dockerignore
@@ -0,0 +1,20 @@
+**/secret.json
+**/secret.txt
+**/secret.yaml
+.git
+.github
+.env
+.vscode/
+bin
+build
+client
+dataserver
+doc
+docker
+docker-compose.yml
+logs
+stream-server
+tinymce-clean-server
+Zend
+zotprime-k8s
+
diff --git a/stream-server b/stream-server
index fc98d3e2..7e2e57d4 160000
--- a/stream-server
+++ b/stream-server
@@ -1 +1 @@
-Subproject commit fc98d3e249bf45a1d31a899ebec1bb0ced6e9f03
+Subproject commit 7e2e57d49ad300dd595ab9e239afe908525097b4
diff --git a/sts.Dockerfile b/sts.Dockerfile
new file mode 100644
index 00000000..ac0e5501
--- /dev/null
+++ b/sts.Dockerfile
@@ -0,0 +1,13 @@
+FROM node:8.9-alpine
+ARG ZOTPRIME_VERSION=2
+
+RUN apk add --update --no-cache \
+libc6-compat
+
+WORKDIR /usr/src/app
+COPY ./stream-server/ .
+COPY docker/stream-server/default.js /usr/src/app/config/
+RUN npm install
+EXPOSE 81/TCP
+CMD [ "npm", "start" ]
+
diff --git a/sts.Dockerfile.dockerignore b/sts.Dockerfile.dockerignore
new file mode 100644
index 00000000..6a94dd94
--- /dev/null
+++ b/sts.Dockerfile.dockerignore
@@ -0,0 +1,21 @@
+**/secret.json
+**/secret.txt
+**/secret.yaml
+.env
+.git
+.github
+.vscode/
+bin
+build
+client
+dataserver
+doc
+docker/dataserver
+docker/db
+docker/miniomc
+docker-compose.yml
+logs
+#stream-server
+tinymce-clean-server
+Zend
+zotprime-k8s
diff --git a/tests/local/include/.gitignore b/tests/local/include/.gitignore
deleted file mode 100644
index 7e9e3afe..00000000
--- a/tests/local/include/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-config.inc.php
diff --git a/tests/local/include/bootstrap.inc.php b/tests/local/include/bootstrap.inc.php
deleted file mode 100644
index e4330c81..00000000
--- a/tests/local/include/bootstrap.inc.php
+++ /dev/null
@@ -1,7 +0,0 @@
-<?
-set_include_path(get_include_path() . PATH_SEPARATOR . "../../include");
-require_once("header.inc.php");
-
-if (!Z_ENV_TESTING_SITE) {
-	throw new Exception("Tests can be run only on testing site");
-}
diff --git a/tests/local/include/config.inc.php-sample b/tests/local/include/config.inc.php-sample
deleted file mode 100644
index 159820c2..00000000
--- a/tests/local/include/config.inc.php-sample
+++ /dev/null
@@ -1,4 +0,0 @@
-<?
-$config = array(
-	"userID" => 0
-);
diff --git a/tests/local/tests/CharacterSetsTest.php b/tests/local/tests/CharacterSetsTest.php
deleted file mode 100644
index 4c61bd74..00000000
--- a/tests/local/tests/CharacterSetsTest.php
+++ /dev/null
@@ -1,9 +0,0 @@
-<?php
-require_once 'include/bootstrap.inc.php';
-
-class CharacterSetsTests extends PHPUnit_Framework_TestCase {
-	public function testToCanonical() {
-		$charset = Zotero_CharacterSets::toCanonical("iso-8859-1");
-		$this->assertEquals("windows-1252", $charset);
-	}
-}
diff --git a/tests/local/tests/CiteTest.php b/tests/local/tests/CiteTest.php
deleted file mode 100644
index c473599a..00000000
--- a/tests/local/tests/CiteTest.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2017 Roy Rosenzweig Center for History and New Media
-                     George Mason University, Fairfax, Virginia, USA
-                     https://www.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 *****
-*/
-require_once 'include/bootstrap.inc.php';
-
-class DateTests extends PHPUnit_Framework_TestCase {
-	public function test_retrieveItem_should_return_correct_date_parts() {
-		$item = new Zotero_Item('book');
-		$item->setField('date', '2017-04-06T07:11:44Z');
-		$cslItem = Zotero_Cite::retrieveItem($item);
-		// "issued": {
-		//     "date-parts": [
-		//         ["2017", 4, 6]
-		//     ]
-		// }
-		$this->assertArrayHasKey('issued', $cslItem);
-		$this->assertArrayHasKey('date-parts', $cslItem['issued']);
-		$this->assertCount(1, $cslItem['issued']['date-parts']);
-		$this->assertCount(3, $cslItem['issued']['date-parts'][0]);
-		$this->assertEquals("2017", $cslItem['issued']['date-parts'][0][0]);
-		$this->assertEquals(4, $cslItem['issued']['date-parts'][0][1]);
-		$this->assertEquals(6, $cslItem['issued']['date-parts'][0][2]);
-	}
-	
-	public function test_retrieveItem_should_use_first_year_from_range() {
-		$item = new Zotero_Item('book');
-		$item->setField('date', '2011-2012');
-		$cslItem = Zotero_Cite::retrieveItem($item);
-		// "issued": {
-		//     "date-parts": [
-		//         ["2011"]
-		//     ],
-		//     "season": "2012"
-		// }
-		$this->assertArrayHasKey('issued', $cslItem);                             
-		$this->assertArrayHasKey('date-parts', $cslItem['issued']);
-		$this->assertCount(1, $cslItem['issued']['date-parts']);
-		$this->assertCount(1, $cslItem['issued']['date-parts'][0]);
-		$this->assertEquals("2011", $cslItem['issued']['date-parts'][0][0]);
-		$this->assertArrayHasKey('season', $cslItem['issued']);
-		$this->assertEquals('2012', $cslItem['issued']['season']);
-	}
-}                                                                                        
diff --git a/tests/local/tests/DBTest.php b/tests/local/tests/DBTest.php
deleted file mode 100644
index e1097800..00000000
--- a/tests/local/tests/DBTest.php
+++ /dev/null
@@ -1,164 +0,0 @@
-<?
-/*
-    ***** 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 *****
-*/
-require_once 'include/bootstrap.inc.php';
-
-class DBTests extends PHPUnit_Framework_TestCase {
-	public function setUp() {
-		Zotero_DB::query("DROP TABLE IF EXISTS test");
-	}
-	
-	public function tearDown() {
-		Zotero_DB::query("DROP TABLE IF EXISTS test");
-		Zotero_DB::query("SET wait_timeout = 28800");
-	}
-	
-	public function testCloseDB() {
-		Zotero_DB::query("SET @foo='bar'");
-		$this->assertEquals("bar", Zotero_DB::valueQuery("SELECT @foo"));
-		Zotero_DB::close();
-		
-		sleep(3);
-		
-		// The false check ensures this is a different connection
-		$this->assertEquals(false, Zotero_DB::valueQuery("SELECT @foo"));
-	}
-	
-	public function testAutoReconnect() {
-		Zotero_DB::query("SET wait_timeout = 1");
-		
-		Zotero_DB::query("SET @foo='bar'");
-		$this->assertEquals("bar", Zotero_DB::valueQuery("SELECT @foo"));
-		
-		sleep(3);
-		
-		try {
-			Zotero_DB::valueQuery("SELECT @foo");
-			$fail = true;
-		}
-		catch (Exception $e) {
-			$this->assertContains("MySQL server has gone away", $e->getMessage());
-		}
-		
-		if (isset($fail)) {
-			$this->fail("Reconnect should not be automatic");
-		}
-		
-		Zotero_DB::close();
-		$this->assertEquals(false, Zotero_DB::valueQuery("SELECT @foo"));
-	}
-	
-	public function testLastInsertIDFromStatement() {
-		Zotero_DB::query("CREATE TABLE test (foo INTEGER PRIMARY KEY NOT NULL AUTO_INCREMENT, foo2 INTEGER NOT NULL)");
-		$sql = "INSERT INTO test VALUES (NULL, ?)";
-		$stmt = Zotero_DB::getStatement($sql, true);
-		$insertID = Zotero_DB::queryFromStatement($stmt, array(1));
-		$this->assertEquals($insertID, 1);
-		$insertID = Zotero_DB::queryFromStatement($stmt, array(2));
-		$this->assertEquals($insertID, 2);
-	}
-	
-	public function testNull() {
-		Zotero_DB::query("CREATE TABLE test (foo INTEGER NULL, foo2 INTEGER NULL DEFAULT NULL)");
-		Zotero_DB::query("INSERT INTO test VALUES (?,?)", array(null, 3));
-		$result = Zotero_DB::query("SELECT * FROM test WHERE foo=?", null);
-		$this->assertNull($result[0]['foo']);
-		$this->assertEquals($result[0]['foo2'], 3);
-	}
-	
-	public function testPreparedStatement() {
-		Zotero_DB::query("CREATE TABLE test (foo INTEGER NULL, foo2 INTEGER NULL DEFAULT NULL)");
-		$stmt = Zotero_DB::getStatement("INSERT INTO test (foo) VALUES (?)");
-		$stmt->execute(array(1));
-		$stmt->execute(array(2));
-		$result = Zotero_DB::columnQuery("SELECT foo FROM test");
-		$this->assertEquals($result[0], 1);
-		$this->assertEquals($result[1], 2);
-	}
-	
-	public function testValueQuery_Null() {
-		Zotero_DB::query("CREATE TABLE test (foo INTEGER NULL)");
-		Zotero_DB::query("INSERT INTO test VALUES (NULL)");
-		$val = Zotero_DB::valueQuery("SELECT * FROM test");
-		$this->assertNull($val);
-	}
-	
-	public function testQuery_boundZero() {
-		Zotero_DB::query("CREATE TABLE test (foo INTEGER, foo2 INTEGER)");
-		Zotero_DB::query("INSERT INTO test VALUES (1, 0)");
-		$this->assertEquals(Zotero_DB::valueQuery("SELECT foo FROM test WHERE foo2=?", 0), 1);
-	}
-	
-	public function testBulkInsert() {
-		Zotero_DB::query("CREATE TABLE test (foo INTEGER, foo2 INTEGER)");
-		$sql = "INSERT INTO test VALUES ";
-		$sets = array(
-			array(1,2),
-			array(2,3),
-			array(3,4),
-			array(4,5),
-			array(5,6),
-			array(6,7)
-		);
-		
-		// Different maxInsertGroups values
-		for ($i=1; $i<8; $i++) {
-			Zotero_DB::bulkInsert($sql, $sets, $i);
-			$rows = Zotero_DB::query("SELECT * FROM test");
-			$this->assertEquals(sizeOf($rows), sizeOf($sets));
-			$rowVals = array();
-			foreach ($rows as $row) {
-				$rowVals[] = array($row['foo'], $row['foo2']);
-			}
-			$this->assertEquals($rowVals, $sets);
-			
-			Zotero_DB::query("DELETE FROM test");
-		}
-		
-		// First val
-		$sets2 = array();
-		$sets2Comp = array();
-		foreach ($sets as $set) {
-			$sets2[] = $set[1];
-			$sets2Comp[] = array(1, $set[1]);
-		}
-		Zotero_DB::bulkInsert($sql, $sets2, 2, 1);
-		$rows = Zotero_DB::query("SELECT * FROM test");
-		$this->assertEquals(sizeOf($rows), sizeOf($sets2Comp));
-		$rowVals = array();
-		foreach ($rows as $row) {
-			$rowVals[] = array($row['foo'], $row['foo2']);
-		}
-		$this->assertEquals($rowVals, $sets2Comp);
-	}
-	
-	public function testID() {
-		$id = Zotero_ID_DB_1::valueQuery("SELECT id FROM items");
-		$this->assertNotEquals(false, $id);
-		
-		$id = Zotero_ID_DB_2::valueQuery("SELECT id FROM items");
-		$this->assertNotEquals(false, $id);
-	}
-}
diff --git a/tests/local/tests/Data/CreatorsTest.php b/tests/local/tests/Data/CreatorsTest.php
deleted file mode 100644
index 65d6ae60..00000000
--- a/tests/local/tests/Data/CreatorsTest.php
+++ /dev/null
@@ -1,148 +0,0 @@
-<?
-/*
-    ***** 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 *****
-*/
-require_once 'include/bootstrap.inc.php';
-
-class CreatorsTests extends PHPUnit_Framework_TestCase {
-	public function testGetDataValuesFromXML() {
-		$xml = <<<'EOD'
-		<data version="6">
-			<creators>
-				<creator libraryID="1" key="AAAAAAAA" dateAdded="2009-04-13 20:43:19" dateModified="2009-04-13 20:43:19">
-					<firstName>A.</firstName>
-					<lastName>Testperson</lastName>
-				</creator>
-				<creator libraryID="1" key="BBBBBBBB" dateAdded="2009-04-13 20:45:18" dateModified="2009-04-13 20:45:18">
-					<firstName>B</firstName>
-					<lastName>Téstër</lastName>
-				</creator>
-				<creator libraryID="1" key="CCCCCCCC" dateAdded="2009-04-13 20:55:12" dateModified="2009-04-13 20:55:12">
-					<name>Center før History and New Media</name>
-					<fieldMode>1</fieldMode>
-				</creator>
-			</creators>
-			<tags>
-				<tag libraryID="1" key="AAAAAAAA" name="Foo" dateAdded="2009-08-06 10:20:06" dateModified="2009-08-06 10:20:06">
-					<items>BBBBBBBB</items>
-				</tag>
-			</tags>
-		</data>
-EOD;
-		$xml = new SimpleXMLElement($xml);
-		$domSXE = dom_import_simplexml($xml->creators);
-		$doc = new DOMDocument();
-		$domSXE = $doc->importNode($domSXE, true);
-		$domSXE = $doc->appendChild($domSXE);
-		
-		$objs = Zotero_Creators::getDataValuesFromXML($doc);
-		
-		usort($objs, function ($a, $b) {
-			if ($a->lastName == $b->lastName) {
-				return 0;
-			}
-			
-			return ($a->lastName < $b->lastName) ? -1 : 1;
-		});
-		
-		$this->assertEquals(sizeOf($objs), 3);
-		$this->assertEquals($objs[0]->fieldMode, 1);
-		$this->assertEquals($objs[0]->firstName, "");
-		$this->assertEquals($objs[0]->lastName, "Center før History and New Media");
-		$this->assertEquals($objs[0]->birthYear, null);
-		
-		$this->assertEquals($objs[1]->fieldMode, 0);
-		$this->assertEquals($objs[1]->firstName, "A.");
-		$this->assertEquals($objs[1]->lastName, "Testperson");
-		$this->assertEquals($objs[1]->birthYear, null);
-		
-		$this->assertEquals($objs[2]->fieldMode, 0);
-		$this->assertEquals($objs[2]->firstName, "B");
-		$this->assertEquals($objs[2]->lastName, "Téstër");
-		$this->assertEquals($objs[2]->birthYear, null);
-	}
-	
-	
-	public function testGetLongDataValueFromXML() {
-		$longName = 'Longfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellowlongfellow';
-		
-		$xml = <<<EOD
-		<data version="6">
-			<creators>
-				<creator libraryID="1" key="AAAAAAAA" dateAdded="2009-04-13 20:43:19" dateModified="2009-04-13 20:43:19">
-					<firstName>A.</firstName>
-					<lastName>Testperson</lastName>
-				</creator>
-				<creator libraryID="1" key="BBBBBBBB" dateAdded="2009-04-13 20:45:18" dateModified="2009-04-13 20:45:18">
-					<firstName>B</firstName>
-					<lastName>$longName</lastName>
-				</creator>
-				<creator libraryID="1" key="CCCCCCCC" dateAdded="2009-04-13 20:55:12" dateModified="2009-04-13 20:55:12">
-					<name>Center før History and New Media</name>
-					<fieldMode>1</fieldMode>
-				</creator>
-			</creators>
-		</data>
-EOD;
-		
-		$doc = new DOMDocument();
-		$doc->loadXML($xml);
-		$node = Zotero_Creators::getLongDataValueFromXML($doc);
-		$this->assertEquals($node->nodeName, 'lastName');
-		$this->assertEquals($node->nodeValue, $longName);
-		
-		$xml = <<<EOD
-		<data version="6">
-			<creators>
-				<creator libraryID="1" key="BBBBBBBB" dateAdded="2009-04-13 20:45:18" dateModified="2009-04-13 20:45:18">
-					<firstName>$longName</firstName>
-					<lastName>Testperson</lastName>
-				</creator>
-			</creators>
-		</data>
-EOD;
-		
-		$doc = new DOMDocument();
-		$doc->loadXML($xml);
-		$node = Zotero_Creators::getLongDataValueFromXML($doc);
-		$this->assertEquals($node->nodeName, 'firstName');
-		$this->assertEquals($node->nodeValue, $longName);
-		
-		$xml = <<<EOD
-		<data version="6">
-			<creators>
-				<creator libraryID="1" key="BBBBBBBB" dateAdded="2009-04-13 20:45:18" dateModified="2009-04-13 20:45:18">
-					<name>$longName</name>
-				</creator>
-			</creators>
-		</data>
-EOD;
-		
-		$doc = new DOMDocument();
-		$doc->loadXML($xml);
-		$node = Zotero_Creators::getLongDataValueFromXML($doc);
-		$this->assertEquals($node->nodeName, 'name');
-		$this->assertEquals($node->nodeValue, $longName);
-	}
-}
diff --git a/tests/local/tests/Data/ItemTest.php b/tests/local/tests/Data/ItemTest.php
deleted file mode 100644
index bb57fe63..00000000
--- a/tests/local/tests/Data/ItemTest.php
+++ /dev/null
@@ -1,139 +0,0 @@
-<?
-/*
-    ***** 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 *****
-*/
-require_once 'include/bootstrap.inc.php';
-
-class ItemTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	
-	public static function setUpBeforeClass() {
-		require("include/config.inc.php");
-		self::$config = $config;
-		self::$config['userLibraryID'] = Zotero_Users::getLibraryIDFromUserID($config['userID']);
-	}
-	
-	public function setUp() {
-		Zotero_Users::clearAllData(self::$config['userID']);
-	}
-	
-	
-	public function testSetItemType() {
-		$itemTypeName = "book";
-		$itemTypeID = Zotero_ItemTypes::getID($itemTypeName);
-		
-		$item = new Zotero_Item;
-		$item->libraryID = self::$config['userLibraryID'];
-		$item->itemTypeID = $itemTypeID;
-		$item->save();
-		$this->assertEquals($itemTypeID, $item->itemTypeID);
-		
-		$item = new Zotero_Item($itemTypeName);
-		$item->libraryID = self::$config['userLibraryID'];
-		$item->save();
-		$this->assertEquals($itemTypeID, $item->itemTypeID);
-		
-		$item = new Zotero_Item($itemTypeID);
-		$item->libraryID = self::$config['userLibraryID'];
-		$item->save();
-		$this->assertEquals($itemTypeID, $item->itemTypeID);
-	}
-	
-	
-	public function testSetItemKeyAfterConstructorItemType() {
-		$item = new Zotero_Item(2);
-		$item->libraryID = self::$config['userLibraryID'];
-		try {
-			$item->key = "AAAAAAAA";
-		}
-		catch (Exception $e) {
-			$this->assertEquals("Cannot set key after item is already loaded", $e->getMessage());
-			return;
-		}
-		
-		$this->fail("Unexpected success setting item key after passing item type to Zotero.Item constructor");
-	}
-	
-	
-	public function testChangeItemTypeByLibraryAndKey() {
-		$item = new Zotero_Item(2);
-		$item->libraryID = self::$config['userLibraryID'];
-		$item->save();
-		$key = $item->key;
-		$this->assertEquals(2, $item->itemTypeID);
-		
-		$item = Zotero_Items::getByLibraryAndKey($item->libraryID, $item->key);
-		$item->itemTypeID = 3;
-		$item->save();
-		$this->assertEquals(3, $item->itemTypeID);
-	}
-	
-	
-	public function testChangeItemTypeByConstructor() {
-		$item = new Zotero_Item(2);
-		$item->libraryID = self::$config['userLibraryID'];
-		$item->save();
-		$key = $item->key;
-		$this->assertEquals(2, $item->itemTypeID);
-		
-		$item = new Zotero_Item;
-		$item->libraryID = self::$config['userLibraryID'];
-		$item->key = $key;
-		$item->itemTypeID = 3;
-		$item->save();
-		$this->assertEquals(3, $item->itemTypeID);
-	}
-	
-	
-	public function testItemVersionAfterSave() {
-		$item = new Zotero_Item("book");
-		$item->libraryID = self::$config['userLibraryID'];
-		$item->save();
-		$this->assertEquals(0, $item->itemVersion);
-		
-		$item->itemTypeID = 3;
-		$item->save();
-		$this->assertEquals(1, $item->itemVersion);
-		
-		$item->setField("title", "Foo");
-		$item->save();
-		$this->assertEquals(2, $item->itemVersion);
-	}
-	
-	
-	public function testNumAttachments() {
-		$item = new Zotero_Item;
-		$item->libraryID = self::$config['userLibraryID'];
-		$item->itemTypeID = Zotero_ItemTypes::getID("book");
-		$item->save();
-		$this->assertEquals(0, $item->numAttachments());
-		
-		$attachmentItem = new Zotero_Item;
-		$attachmentItem->libraryID = self::$config['userLibraryID'];
-		$attachmentItem->itemTypeID = Zotero_ItemTypes::getID("attachment");
-		$attachmentItem->setSource($item->id);
-		$attachmentItem->save();
-		$this->assertEquals(1, $item->numAttachments());
-	}
-}
diff --git a/tests/local/tests/Data/ItemsTest.php b/tests/local/tests/Data/ItemsTest.php
deleted file mode 100644
index f6700a6a..00000000
--- a/tests/local/tests/Data/ItemsTest.php
+++ /dev/null
@@ -1,132 +0,0 @@
-<?
-/*
-    ***** 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 *****
-*/
-require_once 'include/bootstrap.inc.php';
-
-class ItemsTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	
-	public static function setUpBeforeClass() {
-		require("include/config.inc.php");
-		self::$config = $config;
-		self::$config['userLibraryID'] = Zotero_Users::getLibraryIDFromUserID($config['userID']);
-	}
-	
-	public function setUp() {
-		Zotero_Users::clearAllData(self::$config['userID']);
-	}
-	
-	
-	public function testExistsByLibraryAndKey() {
-		$this->assertFalse(Zotero_Items::existsByLibraryAndKey(self::$config['userLibraryID'], "AAAAAAAA"));
-		
-		$item = new Zotero_Item;
-		$item->libraryID = self::$config['userLibraryID'];
-		$item->itemTypeID = Zotero_ItemTypes::getID("book");
-		$item->save();
-		$key = $item->key;
-		
-		$this->assertTrue(Zotero_Items::existsByLibraryAndKey(self::$config['userLibraryID'], $key));
-		
-		Zotero_Items::delete(self::$config['userLibraryID'], $key);
-		
-		$this->assertFalse(Zotero_Items::existsByLibraryAndKey(self::$config['userLibraryID'], $key));
-	}
-	
-	
-	public function testGetDataValuesFromXML() {
-		$xml = <<<'EOD'
-			<data>
-				<items>
-					<item libraryID="1" key="AAAAAAAA" itemType="journalArticle" dateAdded="2010-01-08 10:29:36" dateModified="2010-01-08 10:29:36">
-						<field name="title">Foo</field>
-						<field name="abstractNote">Bar bar bar
-Bar bar</field>
-						<creator libraryID="1" key="AAAAAAAA" creatorType="author" index="0">
-							<creator libraryID="1" key="AAAAAAAA" dateAdded="2010-01-08 10:29:36" dateModified="2010-01-08 10:29:36">
-								<firstName>Irrelevant</firstName>
-								<lastName>Creator</lastName>
-							</creator>
-						</creator>
-					</item>
-					<item libraryID="1" key="BBBBBBBB" itemType="attachment" dateAdded="2010-01-08 10:31:09" dateModified="2010-01-08 10:31:17" sourceItem="VN9DPHBB" linkMode="0" mimeType="application/pdf" storageModTime="1262946676" storageHash="41125f70cc25117b0da961bd7108938 9">
-						<field name="title">Test_Filename.pdf</field>
-					</item>
-					<item libraryID="1" key="CCCCCCCC" itemType="journalArticle" dateAdded="2010-01-08 10:34:03" dateModified="2010-01-08 10:34:03">
-						<field name="title">Tést 汉字漢字</field>
-						<field name="volume">38</field>
-						<field name="pages">546-553</field>
-						<field name="date">1990-06-00 May - Jun., 1990</field>
-					</item>
-				</items>
-			</data>
-EOD;
-		$xml = new SimpleXMLElement($xml);
-		$domSXE = dom_import_simplexml($xml->items);
-		$doc = new DOMDocument();
-		$domSXE = $doc->importNode($domSXE, true);
-		$domSXE = $doc->appendChild($domSXE);
-		
-		$values = Zotero_Items::getDataValuesFromXML($doc);
-		sort($values);
-		$this->assertEquals(sizeOf($values), 7);
-		$this->assertEquals($values[0], "1990-06-00 May - Jun., 1990");
-		$this->assertEquals($values[1], "38");
-		$this->assertEquals($values[2], "546-553");
-		$this->assertEquals($values[3], "Bar bar bar\nBar bar");
-		$this->assertEquals($values[4], "Foo");
-		$this->assertEquals($values[5], "Test_Filename.pdf");
-		$this->assertEquals($values[6], "Tést 汉字漢字");
-	}
-	
-	
-	public function testGetLongDataValueFromXML() {
-		$longStr = str_pad("", 65534, "-") . "\nFoobar";
-		$xml = <<<EOD
-			<data>
-				<items>
-					<item libraryID="1" key="BBBBBBBB" itemType="attachment" dateAdded="2010-01-08 10:31:09" dateModified="2010-01-08 10:31:17" sourceItem="VN9DPHBB" linkMode="0" mimeType="application/pdf" storageModTime="1262946676" storageHash="41125f70cc25117b0da961bd7108938 9">
-						<field name="title">Test_Filename.pdf</field>
-					</item>
-					<item libraryID="1" key="AAAAAAAA" itemType="journalArticle" dateAdded="2010-01-08 10:29:36" dateModified="2010-01-08 10:29:36">
-						<field name="title">Foo</field>
-						<field name="abstractNote">$longStr</field>
-						<creator libraryID="1" key="AAAAAAAA" creatorType="author" index="0">
-							<creator libraryID="1" key="AAAAAAAA" dateAdded="2010-01-08 10:29:36" dateModified="2010-01-08 10:29:36">
-								<firstName>Irrelevant</firstName>
-								<lastName>Creator</lastName>
-							</creator>
-						</creator>
-					</item>
-				</items>
-			</data>
-EOD;
-		$doc = new DOMDocument();
-		$doc->loadXML($xml);
-		$node = Zotero_Items::getLongDataValueFromXML($doc);
-		$this->assertEquals("abstractNote", $node->getAttribute('name'));
-		$this->assertEquals($longStr, $node->nodeValue);
-	}
-}
diff --git a/tests/local/tests/Data/TagsTest.php b/tests/local/tests/Data/TagsTest.php
deleted file mode 100644
index 218774ea..00000000
--- a/tests/local/tests/Data/TagsTest.php
+++ /dev/null
@@ -1,80 +0,0 @@
-<?
-/*
-    ***** 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 *****
-*/
-require_once 'include/bootstrap.inc.php';
-
-class TagsTests extends PHPUnit_Framework_TestCase {
-	/*public function testGetDataValuesFromXML() {
-		$xml = <<<'EOD'
-			<data>
-				<creators><creator/></creators>
-				<tags>
-					<tag libraryID="1" key="AAAAAAAA" name="Animal" dateAdded="2009-04-13 12:22:31" dateModified="2009-08-06 10:21:20">
-						<items>AAAAAAAA</items>
-					</tag>
-					<tag libraryID="1" key="BBBBBBBB" name="Vegetable" dateAdded="2009-04-13 12:22:31" dateModified="2009-08-06 10:21:20"/>
-					<tag libraryID="1" key="CCCCCCCC" name="Mineral" dateAdded="2009-04-13 12:22:31" dateModified="2009-08-06 10:21:20"/>
-					<tag libraryID="1" key="DDDDDDDD" name="mineral" dateAdded="2009-04-13 12:22:31" dateModified="2009-08-06 10:21:20"/>
-					<tag libraryID="1" key="EEEEEEEE" name="Minéral" dateAdded="2009-04-13 12:22:31" dateModified="2009-08-06 10:21:20"/>
-					<tag libraryID="2" key="FFFFFFFF" name="Animal" dateAdded="2009-04-13 12:22:31" dateModified="2009-08-06 10:21:20"/>
-				</tags>
-			</data>
-EOD;
-		$xml = new SimpleXMLElement($xml);
-		$domSXE = dom_import_simplexml($xml->tags);
-		$doc = new DOMDocument();
-		$domSXE = $doc->importNode($domSXE, true);
-		$domSXE = $doc->appendChild($domSXE);
-		
-		$values = Zotero_Tags::getDataValuesFromXML($doc);
-		sort($values);
-		$this->assertEquals(5, sizeOf($values));
-		$this->assertEquals("Animal", $values[0]);
-		$this->assertEquals("Mineral", $values[1]);
-		$this->assertEquals("Minéral", $values[2]);
-		$this->assertEquals("Vegetable", $values[3]);
-		$this->assertEquals("mineral", $values[4]);
-	}*/
-	
-	
-	public function testGetLongDataValueFromXML() {
-		$longTag = "Longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglong";
-		$xml = <<<EOD
-			<data>
-				<creators><creator/></creators>
-				<tags>
-					<tag libraryID="1" key="AAAAAAAA" name="Test" dateAdded="2009-04-13 12:22:31" dateModified="2009-08-06 10:21:20">
-						<items>AAAAAAAA</items>
-					</tag>
-					<tag libraryID="1" key="BBBBBBBB" name="$longTag" dateAdded="2009-04-13 12:22:31" dateModified="2009-08-06 10:21:20"/>
-				</tags>
-			</data>
-EOD;
-		$doc = new DOMDocument();
-		$doc->loadXML($xml);
-		$tag = Zotero_Tags::getLongDataValueFromXML($doc);
-		$this->assertEquals($longTag, $tag);
-	}
-}
diff --git a/tests/local/tests/DateTest.php b/tests/local/tests/DateTest.php
deleted file mode 100644
index ffcee9d1..00000000
--- a/tests/local/tests/DateTest.php
+++ /dev/null
@@ -1,111 +0,0 @@
-<?
-/*
-    ***** 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 *****
-*/
-require_once 'include/bootstrap.inc.php';
-
-class DateTests extends PHPUnit_Framework_TestCase {
-	public function test_strToDate() {
-		$patterns = array(
-			"February 28, 2011",
-			"2011-02-28",
-			"28-02-2011",
-			"Feb 28 2011",
-			"28 Feb 2011"
-		);
-		
-		foreach ($patterns as $pattern) {
-			$parts = Zotero_Date::strToDate($pattern);
-			$this->assertEquals(2011, $parts['year']);
-			$this->assertEquals(2, $parts['month']);
-			$this->assertEquals(28, $parts['day']);
-			$this->assertFalse(isset($parts['part']));
-		}
-	}
-	
-	
-	public function test_strToDate_monthYear() {
-		$patterns = array(
-			//"9/10",
-			//"09/10",
-			//"9/2010",
-			//"09/2010",
-			//"09-2010",
-			"September 2010",
-			"Sep 2010",
-			"Sep. 2010"
-		);
-		
-		foreach ($patterns as $pattern) {
-			$parts = Zotero_Date::strToDate($pattern);
-			$this->assertEquals(2010, $parts['year']);
-			$this->assertEquals(9, $parts['month']);
-			$this->assertFalse(isset($parts['day']));
-			$this->assertFalse(isset($parts['part']));
-		}
-	}
-	
-	
-	public function test_strToDate_yearRange() {
-		$pattern = "1983-84";
-		$parts = Zotero_Date::strToDate($pattern);
-		$this->assertEquals(1983, $parts['year']);
-		$this->assertFalse(isset($parts['month']));
-		$this->assertFalse(isset($parts['day']));
-		$this->assertEquals("84", $parts['part']);
-		
-		$pattern = "1983-1984";
-		$parts = Zotero_Date::strToDate($pattern);
-		$this->assertEquals(1983, $parts['year']);
-		$this->assertFalse(isset($parts['month']));
-		$this->assertFalse(isset($parts['day']));
-		$this->assertEquals("1984", $parts['part']);
-	}
-	
-	
-	/*public function test_strToDate_BCE() {
-		$patterns = array(
-			"c380 BC/1935",
-			"2009 BC",
-			"2009 B.C.",
-			"2009 BCE",
-			"2009 B.C.E.",
-			"2009BC",
-			"2009BCE",
-			"2009B.C.",
-			"2009B.C.E.",
-			"c2009BC",
-			"c2009BCE",
-			"~2009BC",
-			"~2009BCE",
-			"-300"
-		);
-		
-		foreach ($patterns as $pattern) {
-			$parts = Zotero_Date::strToDate($pattern);
-			var_dump($parts['year']);
-			$this->assertTrue(!!preg_match("/^[0-9]+$/", $parts['year']));
-		}
-	}*/
-}
diff --git a/tests/local/tests/MemcacheTest.php b/tests/local/tests/MemcacheTest.php
deleted file mode 100644
index b1c519f3..00000000
--- a/tests/local/tests/MemcacheTest.php
+++ /dev/null
@@ -1,140 +0,0 @@
-<?
-/*
-    ***** 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 *****
-*/
-require_once 'include/bootstrap.inc.php';
-
-class MemcacheTests extends PHPUnit_Framework_TestCase {
-	public function testQueue() {
-		// Clean up
-		Z_Core::$MC->rollback(true);
-		Z_Core::$MC->delete("testFoo");
-		Z_Core::$MC->delete("testFoo2");
-		Z_Core::$MC->delete("testFoo3");
-		Z_Core::$MC->delete("testDeleted");
-		
-		// Used below
-		Z_Core::$MC->set("testDeleted1", "foo1");
-		
-		Z_Core::$MC->begin();
-		Z_Core::$MC->set("testFoo", "bar");
-		Z_Core::$MC->set("testFoo", "bar2");
-		Z_Core::$MC->add("testFoo", "bar3"); // should be ignored
-		
-		Z_Core::$MC->add("testFoo2", "bar4");
-		
-		Z_Core::$MC->add("testFoo3", "bar5");
-		Z_Core::$MC->set("testFoo3", "bar6");
-		
-		// Gets within a transaction should return the queued value
-		$this->assertEquals(Z_Core::$MC->get("testFoo"), "bar2");
-		$this->assertEquals(Z_Core::$MC->get("testFoo2"), "bar4");
-		$this->assertEquals(Z_Core::$MC->get("testFoo3"), "bar6");
-		
-		// Multi-gets within a transaction should return the queued value
-		$arr = array("testFoo" => "bar2", "testFoo2" => "bar4", "testFoo3" => "bar6");
-		$this->assertEquals(Z_Core::$MC->get(array("testFoo", "testFoo2", "testFoo3")), $arr);
-		
-		// Gets for a deleted key within the transaction should return false,
-		// whether the key was set before or during the transaction
-		Z_Core::$MC->set("testDeleted2", "foo2");
-		Z_Core::$MC->delete("testDeleted1");
-		$this->assertFalse(Z_Core::$MC->get("testDeleted1"));
-		Z_Core::$MC->delete("testDeleted2");
-		$this->assertFalse(Z_Core::$MC->get("testDeleted2"));
-		
-		Z_Core::$MC->commit();
-		
-		$this->assertEquals(Z_Core::$MC->get("testFoo"), "bar2");
-		$this->assertEquals(Z_Core::$MC->get("testFoo2"), "bar4");
-		$this->assertEquals(Z_Core::$MC->get("testFoo3"), "bar6");
-		$this->assertFalse(Z_Core::$MC->get("testDeleted"));
-		
-		// Clean up
-		Z_Core::$MC->delete("testFoo");
-		Z_Core::$MC->delete("testFoo2");
-		Z_Core::$MC->delete("testFoo3");
-		Z_Core::$MC->delete("testDeleted");
-	}
-	
-	public function testUnicode() {
-		// Clean up
-		Z_Core::$MC->rollback(true);
-		Z_Core::$MC->delete("testUnicode1");
-		Z_Core::$MC->delete("testUnicode2");
-		
-		$str1 = "Øüévrê";
-		$str2 = "汉字漢字";
-		
-		Z_Core::$MC->set("testUnicode1", $str1 . $str2);
-		$this->assertEquals(Z_Core::$MC->get("testUnicode1"), $str1 . $str2);
-		
-		$arr = array('foo1' => $str1, 'foo2' => $str2);
-		Z_Core::$MC->set("testUnicode2", $arr);
-		$this->assertEquals(Z_Core::$MC->get("testUnicode2"), $arr);
-		
-		// Clean up
-		Z_Core::$MC->delete("testUnicode1");
-		Z_Core::$MC->delete("testUnicode2");
-	}
-	
-	public function testNonExistent() {
-		// Clean up
-		Z_Core::$MC->rollback(true);
-		Z_Core::$MC->delete("testMissing");
-		Z_Core::$MC->delete("testZero");
-		
-		Z_Core::$MC->set("testZero", 0);
-		
-		$this->assertFalse(Z_Core::$MC->get("testMissing"));
-		$this->assertTrue(0 === Z_Core::$MC->get("testZero"));
-		
-		// Clean up
-		Z_Core::$MC->delete("testZero");
-	}
-	
-	public function testMultiGet() {
-		// Clean up
-		Z_Core::$MC->rollback(true);
-		Z_Core::$MC->delete("testFoo");
-		Z_Core::$MC->delete("testFoo2");
-		Z_Core::$MC->delete("testFoo3");
-		
-		Z_Core::$MC->set("testFoo", "bar");
-		Z_Core::$MC->set("testFoo2", "bar2");
-		Z_Core::$MC->set("testFoo3", "bar3");
-		
-		$arr = array(
-			"testFoo" => "bar",
-			"testFoo2" => "bar2",
-			"testFoo3" => "bar3"
-		);
-		$this->assertEquals(Z_Core::$MC->get(array("testFoo", "testFoo2", "testFoo3")), $arr);
-		
-		// Clean up
-		Z_Core::$MC->delete("testFoo");
-		Z_Core::$MC->delete("testFoo2");
-		Z_Core::$MC->delete("testFoo3");
-	}
-}
diff --git a/tests/local/tests/NotifierTest.php b/tests/local/tests/NotifierTest.php
deleted file mode 100644
index 45764b76..00000000
--- a/tests/local/tests/NotifierTest.php
+++ /dev/null
@@ -1,138 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-require_once 'include/bootstrap.inc.php';
-
-class NotifierTests extends PHPUnit_Framework_TestCase {
-	public function testNotify() {
-		$event = "modify";
-		$type = "item";
-		$libraryKey = "4/DDDDDDDD";
-		
-		$mock = $this->getMock('stdClass', array('notify'));
-		$mock->expects($this->once())
-				->method('notify')
-				->with(
-					$event,
-					$type,
-					array($libraryKey)
-				);
-		
-		$hash = Zotero_Notifier::registerObserver($mock, $type);
-		$this->assertEquals(2, strlen($hash));
-		
-		Zotero_Notifier::trigger($event, $type, $libraryKey);
-		
-		Zotero_Notifier::unregisterObserver($hash);
-	}
-	
-	
-	public function testNotifyMultipleLibraryKeys() {
-		$event = "add";
-		$type = "item";
-		$libraryKeys = array("1/ABCD2345", "1/BCDE3456", "1/CDEF4567");
-		
-		$mock = $this->getMock('stdClass', array('notify'));
-		$mock->expects($this->once())
-				->method('notify')
-				->with(
-					$event,
-					$type,
-					$libraryKeys
-				);
-		
-		$hash = Zotero_Notifier::registerObserver($mock, $type);
-		
-		Zotero_Notifier::trigger($event, $type, $libraryKeys);
-		
-		Zotero_Notifier::unregisterObserver($hash);
-	}
-	
-	
-	public function testSkipType() {
-		$mock = $this->getMock('stdClass', array('notify'));
-		$mock->expects($this->never())->method('notify');
-		
-		$hash = Zotero_Notifier::registerObserver($mock, "item");
-		Zotero_Notifier::trigger("add", "collection", "2/ABABABAB");
-		
-		Zotero_Notifier::unregisterObserver($hash);
-	}
-	
-	
-	public function testUnregisterObserver() {
-		$mock = $this->getMock('stdClass', array('notify'));
-		$mock->expects($this->never())->method('notify');
-		
-		$hash = Zotero_Notifier::registerObserver($mock, "item");
-		Zotero_Notifier::unregisterObserver($hash);
-		
-		Zotero_Notifier::trigger("add", "item", "3/CADACADA");
-	}
-	
-	
-	public function testQueue() {
-		$event = "add";
-		$type = "item";
-		$keys = array("1/AAAAAAAA", "1/BBBBBBBB");
-		
-		$mock = $this->getMock('stdClass', array('notify'));
-		$mock->expects($this->once())
-				->method('notify')
-				->with(
-					$event,
-					$type,
-					array($keys[0], $keys[1])
-				);
-		
-		$hash = Zotero_Notifier::registerObserver($mock, $type);
-		
-		Zotero_Notifier::begin();
-		Zotero_Notifier::trigger($event, $type, $keys[0]);
-		Zotero_Notifier::trigger($event, $type, $keys[1]);
-		Zotero_Notifier::commit();
-		
-		Zotero_Notifier::unregisterObserver($hash);
-	}
-	
-	
-	public function testReset() {
-		$event = "add";
-		$type = "item";
-		
-		$mock = $this->getMock('stdClass', array('notify'));
-		$mock->expects($this->never())->method('notify');
-		
-		$hash = Zotero_Notifier::registerObserver($mock, $type);
-		
-		Zotero_Notifier::begin();
-		Zotero_Notifier::trigger($event, $type, "1/AAAAAAAA");
-		Zotero_Notifier::trigger($event, $type, "1/BBBBBBBB");
-		Zotero_Notifier::reset();
-		Zotero_Notifier::commit();
-		
-		Zotero_Notifier::unregisterObserver($hash);
-	}
-}
diff --git a/tests/local/tests/UnicodeTest.php b/tests/local/tests/UnicodeTest.php
deleted file mode 100644
index 52d52133..00000000
--- a/tests/local/tests/UnicodeTest.php
+++ /dev/null
@@ -1,36 +0,0 @@
-<?
-/*
-    ***** 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 *****
-*/
-require_once 'include/bootstrap.inc.php';
-
-class UnicodeTests extends PHPUnit_Framework_TestCase {
-	public function testConvert() {
-		$original = "Abcdefg Âéìøü 这是一个测试。";
-		$str = Zotero_Attachments::encodeRelativeDescriptorString($original);
-		// assert
-		$str = Zotero_Attachments::decodeRelativeDescriptorString($str);
-		$this->assertEquals($original, $str);
-	}
-}
diff --git a/tests/local/tests/UsersTest.php b/tests/local/tests/UsersTest.php
deleted file mode 100644
index 9910c6ea..00000000
--- a/tests/local/tests/UsersTest.php
+++ /dev/null
@@ -1,38 +0,0 @@
-<?
-/*
-    ***** 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 *****
-*/
-require_once 'include/bootstrap.inc.php';
-
-class UsersTests extends PHPUnit_Framework_TestCase {
-	public function testExists() {
-		$this->assertTrue(Zotero_Users::exists(1));
-		$this->assertFalse(Zotero_Users::exists(100));
-	}
-	
-	public function testAuthenticate() {
-		$this->assertEquals(1, Zotero_Users::authenticate('password', ['username'=>'testuser', 'password'=>'letmein']));
-		$this->assertFalse(Zotero_Users::authenticate('password', ['username'=>'testuser', 'password'=>'letmein2']));
-	}
-}
diff --git a/tests/remote/data/bad_string.xml b/tests/remote/data/bad_string.xml
deleted file mode 100644
index 5cf31734..00000000
--- a/tests/remote/data/bad_string.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-<p>&nbsp;</p>
-<p style="margin-top: 0.18cm; margin-bottom: 0.18cm; line-height: 100%;" lang="es-ES"><br /><br /></p>
-<p style="margin-top: 0.18cm; margin-bottom: 0.18cm; line-height: 100%;" lang="es-ES"><br /><br /></p>
-<table border="1" cellspacing="0" cellpadding="7" width="614">
-<colgroup><col width="598"></col> </colgroup> 
-<p style="margin-top: 0.18cm; margin-bottom: 0.18cm;" lang="en-US"><span style="font-family: Times New Roman,serif;"><span style="font-size: x-large;"><strong>test</strong></span></span></p>
diff --git a/tests/remote/data/sync1download.xml b/tests/remote/data/sync1download.xml
deleted file mode 100644
index 97be9141..00000000
--- a/tests/remote/data/sync1download.xml
+++ /dev/null
@@ -1,123 +0,0 @@
-<?xml version="1.0"?>
-<response version="9" timestamp="" userID="23848" defaultLibraryID="21999" updateKey="" earliest="">
-	<updated>
-		<creators>
-			<creator key="DMAHQQR9" dateAdded="2009-03-07 04:59:21" dateModified="2009-03-07 04:59:23">
-				<firstName>Døn</firstName>
-				<lastName>Stîllmån</lastName>
-			</creator>
-			<creator key="I2QFIFDT" dateAdded="2009-03-07 04:54:17" dateModified="2009-03-07 04:54:17">
-				<name>汉字</name>
-				<fieldMode>1</fieldMode>
-			</creator>
-			<creator key="V9MECAGR" dateAdded="2009-03-07 04:53:27" dateModified="2009-03-07 04:53:28">
-				<firstName>Test</firstName>
-				<lastName>Testerman</lastName>
-			</creator>
-			<creator key="WTEG7VPJ" dateAdded="2009-03-07 04:53:37" dateModified="2009-03-07 04:53:40">
-				<name>Testish McTester</name>
-				<fieldMode>1</fieldMode>
-			</creator>
-			<creator key="ZT4UDTTT" dateAdded="2009-03-07 04:54:37" dateModified="2009-03-07 04:54:42">
-				<firstName>Testy</firstName>
-				<lastName>Teststein</lastName>
-			</creator>
-		</creators>
-		<items>
-			<item key="2TGWDASM" itemType="note" dateAdded="2009-03-07 04:55:36" dateModified="2009-03-07 04:55:46" sourceItem="DUQPU87V">
-				<note><p>Here's a <strong>child</strong> note.</p></note>
-			</item>
-			<item key="6TKKAABJ" itemType="bookSection" dateAdded="2009-03-07 04:53:20" dateModified="2009-03-07 04:54:09">
-				<field name="edition">3</field>
-				<field name="date">March 6, 2007</field>
-				<field name="title">My Book Section</field>
-				<creator key="V9MECAGR" creatorType="author" index="0"/>
-				<creator key="WTEG7VPJ" creatorType="editor" index="1"/>
-				<related>DUQPU87V</related>
-			</item>
-			<item key="7IMJZ8V6" itemType="attachment" dateAdded="2009-03-07 05:00:17" dateModified="2009-03-07 05:00:42" linkMode="2" mimeType="text/html" charset="utf-8">
-				<field name="title">amazon.html</field>
-				<path>AAAAAAFkAAIAAAxNYWNpbnRvc2ggSEQAAAAAAAAAAAAAAAAAAADC/J9aSCsAAAAOrpkLYW1hem9uLmh0bWwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADnT/bcS9TKEAAAAAAAAAAP////8AAAkgAAAAAAAAAAAAAAAAAAAAB0Rlc2t0b3AAABAACAAAwvzXmgAAABEACAAAxL2E4QAAAAEADAAOrpkADjPnAA4z5QACACpNYWNpbnRvc2ggSEQ6VXNlcnM6ZGFuOkRlc2t0b3A6YW1hem9uLmh0bWwADgAYAAsAYQBtAGEAegBvAG4ALgBoAHQAbQBsAA8AGgAMAE0AYQBjAGkAbgB0AG8AcwBoACAASABEABIAHVVzZXJzL2Rhbi9EZXNrdG9wL2FtYXpvbi5odG1sAAATAAEvAAAVAAIACv//AAA=</path>
-				<note><p>Note on a top-level linked file</p></note>
-				<related>DUQPU87V</related>
-			</item>
-			<item key="976Q6TWE" itemType="attachment" dateAdded="2009-03-07 04:55:59" dateModified="2009-03-07 04:56:16" sourceItem="DUQPU87V" linkMode="1" mimeType="text/html" charset="utf-8">
-				<field name="url">http://chnm.gmu.edu/</field>
-				<field name="accessDate">2009-03-07 04:55:59</field>
-				<field name="title">Center for History and New Media</field>
-				<path>storage:chnm.gmu.edu.html</path>
-				<note><p>This is a note for a snapshot.</p></note>
-			</item>
-			<item key="9P9UVFK3" itemType="note" dateAdded="2009-03-07 04:56:20" dateModified="2009-03-07 04:56:29">
-				<note><p>Here's a top-level note.</p></note>
-			</item>
-			<item key="C2W49E6Q" itemType="attachment" dateAdded="2009-03-07 04:56:47" dateModified="2009-03-07 04:56:47" linkMode="1" mimeType="text/html" charset="utf-8">
-				<field name="url">http://www.zotero.org/</field>
-				<field name="accessDate">2009-03-07 04:56:47</field>
-				<field name="title">Zotero: The Next-Generation Research Tool</field>
-				<path>storage:www.zotero.org.html</path>
-			</item>
-			<item key="DUQPU87V" itemType="book" dateAdded="2009-03-07 04:52:56" dateModified="2009-03-07 04:55:18">
-				<field name="title">My Book</field>
-				<creator key="I2QFIFDT" creatorType="translator" index="0"/>
-				<creator key="ZT4UDTTT" creatorType="author" index="1"/>
-				<related>6TKKAABJ 7IMJZ8V6</related>
-			</item>
-			<item key="G3PTXNXS" itemType="attachment" dateAdded="2009-03-07 04:56:01" dateModified="2009-03-07 04:56:10" sourceItem="DUQPU87V" linkMode="3" mimeType="text/html" charset="utf-8">
-				<field name="url">http://chnm.gmu.edu/</field>
-				<field name="accessDate">2009-03-07 04:56:01</field>
-				<field name="title">Center for History and New Media</field>
-				<note><p>This is a note for a link.</p></note>
-			</item>
-			<item key="HTHD884W" itemType="attachment" dateAdded="2009-03-07 04:58:37" dateModified="2009-03-07 04:58:37" linkMode="0" mimeType="image/jpeg">
-				<field name="title">FILE.jpg</field>
-				<path>storage:FILE.jpg</path>
-			</item>
-			<item key="T3K4BNWP" itemType="book" dateAdded="2009-03-07 04:59:04" dateModified="2009-03-07 04:59:39" deleted="1">
-				<field name="title">Trashed item</field>
-				<creator key="DMAHQQR9" creatorType="author" index="0"/>
-			</item>
-		</items>
-		<collections>
-			<collection key="8VE7W6RN" name="Subcollection" dateAdded="2009-03-07 04:57:19" dateModified="2009-03-07 04:57:25" parent="KBTFHUZD">
-				<items>DUQPU87V</items>
-			</collection>
-			<collection key="9Z7WTWC2" name="Another subcollection" dateAdded="2009-03-07 04:57:53" dateModified="2009-03-07 04:58:38" parent="8VE7W6RN">
-				<items>HTHD884W</items>
-			</collection>
-			<collection key="KBTFHUZD" name="My Collection" dateAdded="2009-03-07 04:55:26" dateModified="2009-03-07 04:56:01">
-				<items>6TKKAABJ 9P9UVFK3</items>
-			</collection>
-			<collection key="M6B882D5" name="Empty subcollection" dateAdded="2009-03-07 04:57:36" dateModified="2009-03-07 04:57:36" parent="8VE7W6RN"/>
-		</collections>
-		<searches>
-			<search key="W2KN2TFW" name="Me" dateAdded="2009-03-07 04:57:10" dateModified="2009-03-07 04:57:10">
-				<condition id="1" condition="title" operator="contains" value="My"/>
-				<condition id="2" condition="edition" operator="is" value="2"/>
-			</search>
-			<search key="Q88FDVN3" name="Notes" dateAdded="2009-03-07 05:01:14" dateModified="2009-03-07 05:01:14">
-				<condition id="1" condition="itemTypeID" operator="is" value="1"/>
-			</search>
-		</searches>
-		<tags>
-			<tag key="MR3BTEPM" name="Birds" dateAdded="2009-03-07 04:55:05" dateModified="2009-03-07 04:55:05">
-				<items>6TKKAABJ</items>
-			</tag>
-			<tag key="TNT9H7Q2" name="Cats" dateAdded="2009-03-07 04:55:02" dateModified="2009-03-07 04:55:02">
-				<items>6TKKAABJ</items>
-			</tag>
-			<tag key="4EQG6EUX" name="Dogs" dateAdded="2009-03-07 04:54:54" dateModified="2009-03-07 04:55:04">
-				<items>6TKKAABJ DUQPU87V</items>
-			</tag>
-			<tag key="IPSDQFTW" name="Fish" dateAdded="2009-03-07 04:54:56" dateModified="2009-03-07 04:54:56">
-				<items>DUQPU87V</items>
-			</tag>
-			<tag key="EAMSZGIN" name="Frogs" dateAdded="2009-03-07 04:58:47" dateModified="2009-03-07 04:58:47">
-				<items>HTHD884W</items>
-			</tag>
-			<tag key="QU5QF6A7" name="Kumquats" dateAdded="2009-03-07 04:59:33" dateModified="2009-03-07 04:59:33">
-				<items>T3K4BNWP</items>
-			</tag>
-		</tags>
-	</updated>
-</response>
diff --git a/tests/remote/data/sync1upload.xml b/tests/remote/data/sync1upload.xml
deleted file mode 100644
index ca0947eb..00000000
--- a/tests/remote/data/sync1upload.xml
+++ /dev/null
@@ -1,120 +0,0 @@
-<data version="9">
-  <creators>
-    <creator libraryID="" dateAdded="2009-03-07 04:53:27" dateModified="2009-03-07 04:53:28" key="V9MECAGR">
-      <firstName>Test</firstName>
-      <lastName>Testerman</lastName>
-    </creator>
-    <creator libraryID="" dateAdded="2009-03-07 04:54:17" dateModified="2009-03-07 04:54:17" key="I2QFIFDT">
-      <name>汉字</name>
-      <fieldMode>1</fieldMode>
-    </creator>
-    <creator libraryID="" dateAdded="2009-03-07 04:54:37" dateModified="2009-03-07 04:54:42" key="ZT4UDTTT">
-      <firstName>Testy</firstName>
-      <lastName>Teststein</lastName>
-    </creator>
-    <creator libraryID="" dateAdded="2009-03-07 04:59:21" dateModified="2009-03-07 04:59:23" key="DMAHQQR9">
-      <firstName>Døn</firstName>
-      <lastName>Stîllmån</lastName>
-    </creator>
-  </creators>
-  <items>
-    <item libraryID="" itemType="bookSection" dateAdded="2009-03-07 04:53:20" dateModified="2009-03-07 04:54:09" key="6TKKAABJ">
-      <field name="edition">3</field>
-      <field name="date">2007-03-06 March 6, 2007</field>
-      <field name="title">My Book Section</field>
-      <creator key="V9MECAGR" creatorType="author" index="0"/>
-      <creator key="WTEG7VPJ" creatorType="editor" index="1">
-        <creator libraryID="" dateAdded="2009-03-07 04:53:37" dateModified="2009-03-07 04:53:40" key="WTEG7VPJ">
-          <name>Testish McTester</name>
-          <fieldMode>1</fieldMode>
-        </creator>
-      </creator>
-    </item>
-    <item libraryID="" itemType="book" dateAdded="2009-03-07 04:52:56" dateModified="2009-03-07 04:55:18" key="DUQPU87V">
-      <field name="title">My Book</field>
-      <creator key="I2QFIFDT" creatorType="translator" index="0"/>
-      <creator key="ZT4UDTTT" creatorType="author" index="1"/>
-      <related>6TKKAABJ</related>
-    </item>
-    <item libraryID="" itemType="note" dateAdded="2009-03-07 04:55:36" dateModified="2009-03-07 04:55:46" key="2TGWDASM" sourceItem="DUQPU87V">
-      <note><p>Here's a <strong>child</strong> note.</p></note>
-    </item>
-    <item libraryID="" itemType="attachment" dateAdded="2009-03-07 04:56:01" dateModified="2009-03-07 04:56:10" key="G3PTXNXS" sourceItem="DUQPU87V" linkMode="3" mimeType="text/html" charset="utf-8">
-      <field name="url">http://chnm.gmu.edu/</field>
-      <field name="accessDate">2009-03-07 04:56:01</field>
-      <field name="title">Center for History and New Media</field>
-      <note><p>This is a note for a link.</p></note>
-    </item>
-    <item libraryID="" itemType="attachment" dateAdded="2009-03-07 04:55:59" dateModified="2009-03-07 04:56:16" key="976Q6TWE" sourceItem="DUQPU87V" linkMode="1" mimeType="text/html" charset="utf-8">
-      <field name="url">http://chnm.gmu.edu/</field>
-      <field name="accessDate">2009-03-07 04:55:59</field>
-      <field name="title">Center for History and New Media</field>
-      <path>storage:chnm.gmu.edu.html</path>
-      <note><p>This is a note for a snapshot.</p></note>
-    </item>
-    <item libraryID="" itemType="note" dateAdded="2009-03-07 04:56:20" dateModified="2009-03-07 04:56:29" key="9P9UVFK3">
-      <note><p>Here's a top-level note.</p></note>
-    </item>
-    <item libraryID="" itemType="attachment" dateAdded="2009-03-07 04:56:47" dateModified="2009-03-07 04:56:47" key="C2W49E6Q" linkMode="1" mimeType="text/html" charset="utf-8">
-      <field name="url">http://www.zotero.org/</field>
-      <field name="accessDate">2009-03-07 04:56:47</field>
-      <field name="title">Zotero: The Next-Generation Research Tool</field>
-      <path>storage:www.zotero.org.html</path>
-    </item>
-    <item libraryID="" itemType="attachment" dateAdded="2009-03-07 04:58:37" dateModified="2009-03-07 04:58:37" key="HTHD884W" linkMode="0" mimeType="image/jpeg">
-      <field name="title">FILE.jpg</field>
-      <path>storage:FILE.jpg</path>
-    </item>
-    <item libraryID="" itemType="book" dateAdded="2009-03-07 04:59:04" dateModified="2009-03-07 04:59:39" key="T3K4BNWP" deleted="1">
-      <field name="title">Trashed item</field>
-      <creator key="DMAHQQR9" creatorType="author" index="0"/>
-    </item>
-    <item libraryID="" itemType="attachment" dateAdded="2009-03-07 05:00:17" dateModified="2009-03-07 05:00:42" key="7IMJZ8V6" linkMode="2" mimeType="text/html" charset="utf-8">
-      <field name="title">amazon.html</field>
-      <path>AAAAAAFkAAIAAAxNYWNpbnRvc2ggSEQAAAAAAAAAAAAAAAAAAADC/J9aSCsAAAAOrpkLYW1hem9uLmh0bWwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADnT/bcS9TKEAAAAAAAAAAP////8AAAkgAAAAAAAAAAAAAAAAAAAAB0Rlc2t0b3AAABAACAAAwvzXmgAAABEACAAAxL2E4QAAAAEADAAOrpkADjPnAA4z5QACACpNYWNpbnRvc2ggSEQ6VXNlcnM6ZGFuOkRlc2t0b3A6YW1hem9uLmh0bWwADgAYAAsAYQBtAGEAegBvAG4ALgBoAHQAbQBsAA8AGgAMAE0AYQBjAGkAbgB0AG8AcwBoACAASABEABIAHVVzZXJzL2Rhbi9EZXNrdG9wL2FtYXpvbi5odG1sAAATAAEvAAAVAAIACv//AAA=</path>
-      <note><p>Note on a top-level linked file</p></note>
-      <related>DUQPU87V</related>
-    </item>
-  </items>
-  <collections>
-    <collection libraryID="" name="My Collection" dateAdded="2009-03-07 04:55:26" dateModified="2009-03-07 04:56:01" key="KBTFHUZD">
-      <items>6TKKAABJ 9P9UVFK3</items>
-    </collection>
-    <collection libraryID="" name="Subcollection" dateAdded="2009-03-07 04:57:19" dateModified="2009-03-07 04:57:25" key="8VE7W6RN" parent="KBTFHUZD">
-      <items>DUQPU87V</items>
-    </collection>
-    <collection libraryID="" name="Empty subcollection" dateAdded="2009-03-07 04:57:36" dateModified="2009-03-07 04:57:36" key="M6B882D5" parent="8VE7W6RN"/>
-    <collection libraryID="" name="Another subcollection" dateAdded="2009-03-07 04:57:53" dateModified="2009-03-07 04:58:38" key="9Z7WTWC2" parent="8VE7W6RN">
-      <items>HTHD884W</items>
-    </collection>
-  </collections>
-  <searches>
-    <search libraryID="" name="Me" dateAdded="2009-03-07 04:57:10" dateModified="2009-03-07 04:57:10" key="W2KN2TFW">
-      <condition id="1" condition="title" operator="contains" value="My"/>
-      <condition id="2" condition="edition" operator="is" value="2"/>
-    </search>
-    <search libraryID="" name="Notes" dateAdded="2009-03-07 05:01:14" dateModified="2009-03-07 05:01:14" key="Q88FDVN3">
-      <condition id="1" condition="itemTypeID" operator="is" value="1"/>
-    </search>
-  </searches>
-  <tags>
-    <tag libraryID="" name="Fish" dateAdded="2009-03-07 04:54:56" dateModified="2009-03-07 04:54:56" key="IPSDQFTW">
-      <items>DUQPU87V</items>
-    </tag>
-    <tag libraryID="" name="Cats" dateAdded="2009-03-07 04:55:02" dateModified="2009-03-07 04:55:02" key="TNT9H7Q2">
-      <items>6TKKAABJ</items>
-    </tag>
-    <tag libraryID="" name="Dogs" dateAdded="2009-03-07 04:54:54" dateModified="2009-03-07 04:55:04" key="4EQG6EUX">
-      <items>6TKKAABJ DUQPU87V</items>
-    </tag>
-    <tag libraryID="" name="Birds" dateAdded="2009-03-07 04:55:05" dateModified="2009-03-07 04:55:05" key="MR3BTEPM">
-      <items>6TKKAABJ</items>
-    </tag>
-    <tag libraryID="" name="Frogs" dateAdded="2009-03-07 04:58:47" dateModified="2009-03-07 04:58:47" key="EAMSZGIN">
-      <items>HTHD884W</items>
-    </tag>
-    <tag libraryID="" name="Kumquats" dateAdded="2009-03-07 04:59:33" dateModified="2009-03-07 04:59:33" key="QU5QF6A7">
-      <items>T3K4BNWP</items>
-    </tag>
-  </tags>
-</data>
diff --git a/tests/remote/include/.gitignore b/tests/remote/include/.gitignore
deleted file mode 100644
index 2fa7ce7c..00000000
--- a/tests/remote/include/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-config.ini
diff --git a/tests/remote/include/api2.inc.php b/tests/remote/include/api2.inc.php
deleted file mode 100644
index b47be0c5..00000000
--- a/tests/remote/include/api2.inc.php
+++ /dev/null
@@ -1,778 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-require_once __DIR__ . '/http.inc.php';
-
-use \API2 as API;
-
-class API2 {
-	private static $config;
-	private static $nsZAPI;
-	private static $apiVersion = false;
-	
-	public static function loadConfig() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		self::$nsZAPI = 'http://zotero.org/ns/api';
-	}
-	
-	
-	public static function useAPIVersion($apiVersion) {
-		self::$apiVersion = $apiVersion;
-	}
-	
-	
-	//
-	// Item modification methods
-	//
-	public static function getItemTemplate($itemType) {
-		$response = self::get("items/new?itemType=$itemType");
-		return json_decode($response->getBody());
-	}
-	
-	
-	public static function createItem($itemType, $data=array(), $context=null, $responseFormat='atom') {
-		$json = self::getItemTemplate($itemType);
-		
-		if ($data) {
-			foreach ($data as $field => $val) {
-				$json->$field = $val;
-			}
-		}
-		
-		$response = self::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		
-		return self::handleCreateResponse('item', $response, $responseFormat, $context);
-	}
-	
-	
-	/**
-	 * POST a JSON item object to the main test user's account
-	 * and return the response
-	 */
-	public static function postItem($json) {
-		return self::postItems(array($json));
-	}
-	
-	
-	/**
-	 * POST a JSON items object to the main test user's account
-	 * and return the response
-	 */
-	public static function postItems($json) {
-		return self::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => $json
-			)),
-			array("Content-Type: application/json")
-		);
-	}
-	
-	
-	public static function groupCreateItem($groupID, $itemType, $context=null, $responseFormat='atom') {
-		$response = self::get("items/new?itemType=$itemType");
-		$json = json_decode($response->getBody());
-		
-		$response = self::groupPost(
-			$groupID,
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		if ($context) {
-			$context->assert200($response);
-		}
-		
-		$json = self::getJSONFromResponse($response);
-		
-		if ($responseFormat != 'json' && sizeOf($json['success']) != 1) {
-			var_dump($json);
-			throw new Exception("Item creation failed");
-		}
-		
-		switch ($responseFormat) {
-		case 'json':
-			return $json;
-		
-		case 'key':
-			return array_shift($json['success']);
-		
-		case 'atom':
-			$itemKey = array_shift($json['success']);
-			return self::groupGetItemXML($groupID, $itemKey, $context);
-		
-		default:
-			throw new Exception("Invalid response format '$responseFormat'");
-		}
-	}
-	
-	
-	public static function createAttachmentItem($linkMode, $data=[], $parentKey=false, $context=false, $responseFormat='atom') {
-		$response = self::get("items/new?itemType=attachment&linkMode=$linkMode");
-		$json = json_decode($response->getBody());
-		foreach ($data as $key => $val) {
-			$json->{$key} = $val;
-		}
-		if ($parentKey) {
-			$json->parentItem = $parentKey;
-		}
-		
-		$response = self::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		if ($context) {
-			$context->assert200($response);
-		}
-		
-		$json = self::getJSONFromResponse($response);
-		
-		if ($responseFormat != 'json' && sizeOf($json['success']) != 1) {
-			var_dump($json);
-			throw new Exception("Item creation failed");
-		}
-		
-		switch ($responseFormat) {
-		case 'json':
-			return $json;
-		
-		case 'key':
-			return array_shift($json['success']);
-		
-		case 'atom':
-			$itemKey = array_shift($json['success']);
-			$xml = self::getItemXML($itemKey, $context);
-			if ($context) {
-				$data = self::parseDataFromAtomEntry($xml);
-				$json = json_decode($data['content']);
-				$context->assertEquals($linkMode, $json->linkMode);
-			}
-			return $xml;
-			
-			return self::getXMLFromResponse($response);
-		
-		default:
-			throw new Exception("Invalid response format '$responseFormat'");
-		}
-	}
-	
-	
-	public static function groupCreateAttachmentItem($groupID, $linkMode, $data=[], $parentKey=false, $context=false, $responseFormat='atom') {
-		$response = self::get("items/new?itemType=attachment&linkMode=$linkMode");
-		$json = json_decode($response->getBody());
-		foreach ($data as $key => $val) {
-			$json->{$key} = $val;
-		}
-		if ($parentKey) {
-			$json->parentItem = $parentKey;
-		}
-		
-		$response = self::groupPost(
-			$groupID,
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		if ($context) {
-			$context->assert200($response);
-		}
-		
-		$json = self::getJSONFromResponse($response);
-		
-		if ($responseFormat != 'json' && sizeOf($json['success']) != 1) {
-			var_dump($json);
-			throw new Exception("Item creation failed");
-		}
-		
-		switch ($responseFormat) {
-		case 'json':
-			return $json;
-		
-		case 'key':
-			return array_shift($json['success']);
-		
-		case 'atom':
-			$itemKey = array_shift($json['success']);
-			$xml = self::groupGetItemXML($groupID, $itemKey, $context);
-			if ($context) {
-				$data = self::parseDataFromAtomEntry($xml);
-				$json = json_decode($data['content']);
-				$context->assertEquals($linkMode, $json->linkMode);
-			}
-			return $xml;
-		
-		default:
-			throw new Exception("Invalid response format '$responseFormat'");
-		}
-	}
-	
-	
-	public static function createNoteItem($text="", $parentKey=false, $context=false, $responseFormat='atom') {
-		$response = self::get("items/new?itemType=note");
-		$json = json_decode($response->getBody());
-		$json->note = $text;
-		if ($parentKey) {
-			$json->parentItem = $parentKey;
-		}
-		
-		$response = self::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		if ($context) {
-			$context->assert200($response);
-		}
-		
-		$json = self::getJSONFromResponse($response);
-		
-		if ($responseFormat != 'json' && sizeOf($json['success']) != 1) {
-			var_dump($json);
-			throw new Exception("Item creation failed");
-		}
-		
-		switch ($responseFormat) {
-		case 'json':
-			return $json;
-		
-		case 'key':
-			return array_shift($json['success']);
-		
-		case 'atom':
-			$itemKey = array_shift($json['success']);
-			$xml = self::getItemXML($itemKey, $context);
-			if ($context) {
-				$data = self::parseDataFromAtomEntry($xml);
-				$json = json_decode($data['content']);
-				$context->assertEquals($text, $json->note);
-			}
-			return $xml;
-		
-		default:
-			throw new Exception("Invalid response format '$responseFormat'");
-		}
-	}
-	
-	
-	public static function createCollection($name, $data=array(), $context=null, $responseFormat='atom') {
-		if (is_array($data)) {
-			$parent = isset($data['parentCollection']) ? $data['parentCollection'] : false;
-			$relations = isset($data['relations']) ? $data['relations'] : new stdClass;
-		}
-		else {
-			$parent = $data ? $data : false;
-			$relations = new stdClass;
-		}
-		
-		$json = array(
-			"collections" => array(
-				array(
-					'name' => $name,
-					'parentCollection' => $parent,
-					'relations' => $relations
-				)
-			)
-		);
-		
-		$response = self::userPost(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		
-		return self::handleCreateResponse('collection', $response, $responseFormat, $context);
-	}
-	
-	
-	public static function createSearch($name, $conditions=array(), $context=null, $responseFormat='atom') {
-		if ($conditions == 'default') {
-			$conditions = array(
-				array(
-					'condition' => 'title',
-					'operator' => 'contains',
-					'value' => 'test'
-				)
-			);
-		}
-		
-		$json = array(
-			"searches" => array(
-				array(
-					'name' => $name,
-					'conditions' => $conditions
-				)
-			)
-		);
-		
-		$response = self::userPost(
-			self::$config['userID'],
-			"searches?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		
-		return self::handleCreateResponse('search', $response, $responseFormat, $context);
-	}
-	
-	
-	public static function getLibraryVersion() {
-		$response = self::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&limit=1"
-		);
-		return $response->getHeader("Last-Modified-Version");
-	}
-	
-	
-	public static function getGroupLibraryVersion($groupID) {
-		$response = self::groupGet(
-			$groupID,
-			"items?key=" . self::$config['apiKey'] . "&format=keys&limit=1"
-		);
-		return $response->getHeader("Last-Modified-Version");
-	}
-	
-	
-	public static function getItemXML($keys, $context=null) {
-		return self::getObjectXML('item', $keys, $context);
-	}
-	
-	
-	public static function groupGetItemXML($groupID, $keys, $context=null) {
-		if (is_scalar($keys)) {
-			$keys = array($keys);
-		}
-		
-		$response = self::groupGet(
-			$groupID,
-			"items?key=" . self::$config['apiKey']
-				. "&itemKey=" . implode(',', $keys) . "&order=itemKeyList"
-				. "&content=json"
-		);
-		if ($context) {
-			$context->assert200($response);
-		}
-		return self::getXMLFromResponse($response);
-	}
-	
-	
-	public static function getXMLFromFirstSuccessItem($response) {
-		$key = self::getFirstSuccessKeyFromResponse($response);
-		self::getItemXML($key);
-	}
-	
-	
-	public static function getCollectionXML($keys, $context=null) {
-		return self::getObjectXML('collection', $keys, $context);
-	}
-	
-	
-	public static function getSearchXML($keys, $context=null) {
-		return self::getObjectXML('search', $keys, $context);
-	}
-	
-	
-	//
-	// HTTP methods
-	//
-	public static function get($url, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		$response = HTTP::get($url, $headers, $auth);
-		if (self::$config['verbose'] >= 2) {
-			echo "\n\n" . $response->getBody() . "\n";
-		}
-		return $response;
-	}
-	
-	public static function userGet($userID, $suffix, $headers=array(), $auth=false) {
-		return self::get("users/$userID/$suffix", $headers, $auth);
-	}
-	
-	public static function groupGet($groupID, $suffix, $headers=array(), $auth=false) {
-		return self::get("groups/$groupID/$suffix", $headers, $auth);
-	}
-	
-	public static function post($url, $data, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		$response = HTTP::post($url, $data, $headers, $auth);
-		return $response;
-	}
-	
-	public static function userPost($userID, $suffix, $data, $headers=array(), $auth=false) {
-		return self::post("users/$userID/$suffix", $data, $headers, $auth);
-	}
-	
-	public static function groupPost($groupID, $suffix, $data, $headers=array(), $auth=false) {
-		return self::post("groups/$groupID/$suffix", $data, $headers, $auth);
-	}
-	
-	public static function put($url, $data, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		$response = HTTP::put($url, $data, $headers, $auth);
-		return $response;
-	}
-	
-	public static function userPut($userID, $suffix, $data, $headers=array(), $auth=false) {
-		return self::put("users/$userID/$suffix", $data, $headers, $auth);
-	}
-	
-	public static function groupPut($groupID, $suffix, $data, $headers=array(), $auth=false) {
-		return self::put("groups/$groupID/$suffix", $data, $headers, $auth);
-	}
-	
-	public static function patch($url, $data, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		$response = HTTP::patch($url, $data, $headers, $auth);
-		return $response;
-	}
-	
-	public static function userPatch($userID, $suffix, $data, $headers=array()) {
-		return self::patch("users/$userID/$suffix", $data, $headers);
-	}
-	
-	public static function head($url, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		$response = HTTP::head($url, $headers, $auth);
-		return $response;
-	}
-	
-	public static function userHead($userID, $suffix, $headers=array(), $auth=false) {
-		return self::head("users/$userID/$suffix", $headers, $auth);
-	}
-	
-	public static function delete($url, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		$response = HTTP::delete($url, $headers, $auth);
-		return $response;
-	}
-	
-	public static function userDelete($userID, $suffix, $headers=array(), $auth=false) {
-		return self::delete("users/$userID/$suffix", $headers, $auth);
-	}
-	
-	public static function groupDelete($groupID, $suffix, $headers=array(), $auth=false) {
-		return self::delete("groups/$groupID/$suffix", $headers, $auth);
-	}
-	
-	
-	public static function userClear($userID) {
-		$response = self::userPost(
-			$userID,
-			"clear",
-			"",
-			array(),
-			array(
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			)
-		);
-		if ($response->getStatus() != 204) {
-			var_dump($response->getBody());
-			throw new Exception("Error clearing user $userID");
-		}
-	}
-	
-	public static function groupClear($groupID) {
-		$response = self::groupPost(
-			$groupID,
-			"clear",
-			"",
-			array(),
-			array(
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			)
-		);
-		if ($response->getStatus() != 204) {
-			var_dump($response->getBody());
-			throw new Exception("Error clearing group $groupID");
-		}
-	}
-	
-	
-	//
-	// Response parsing
-	//
-	public static function getXMLFromResponse($response) {
-		try {
-			$xml = new SimpleXMLElement($response->getBody());
-		}
-		catch (Exception $e) {
-			var_dump($response->getBody());
-			throw $e;
-		}
-		$xml->registerXPathNamespace('atom', 'http://www.w3.org/2005/Atom');
-		$xml->registerXPathNamespace('zapi', 'http://zotero.org/ns/api');
-		return $xml;
-	}
-	
-	
-	public static function getJSONFromResponse($response) {
-		$json = json_decode($response->getBody(), true);
-		if (is_null($json)) {
-			var_dump($response->getBody());
-			throw new Exception("JSON response could not be parsed");
-		}
-		return $json;
-	}
-	
-	
-	public static function getFirstSuccessKeyFromResponse($response) {
-		$json = self::getJSONFromResponse($response);
-		if (empty($json['success'])) {
-			var_dump($response->getBody());
-			throw new Exception("No success keys found in response");
-		}
-		return array_shift($json['success']);
-	}
-	
-	
-	public static function parseDataFromAtomEntry($entryXML) {
-		$key = (string) array_get_first($entryXML->xpath('//atom:entry/zapi:key'));
-		$version = (string) array_get_first($entryXML->xpath('//atom:entry/zapi:version'));
-		$content = array_get_first($entryXML->xpath('//atom:entry/atom:content'));
-		if (is_null($content)) {
-			throw new Exception("Atom response does not contain <content>");
-		}
-		
-		// If 'content' contains XML, serialize all subnodes
-		if ($content->count()) {
-			$content = $content->asXML();
-		}
-		// Otherwise just get string content
-		else {
-			$content = (string) $content;
-		}
-		
-		return array(
-			"key" => $key,
-			"version" => $version,
-			"content" => $content
-		);
-	}
-	
-	
-	public static function getContentFromResponse($response) {
-		$xml = self::getXMLFromResponse($response);
-		$data = self::parseDataFromAtomEntry($xml);
-		return $data['content'];
-	}
-	
-	
-	public static function setKeyOption($userID, $key, $option, $val) {
-		$response = self::get(
-			"users/$userID/keys/$key",
-			array(),
-			array(
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			)
-		);
-		if ($response->getStatus() != 200) {
-			var_dump($response->getBody());
-			throw new Exception("GET returned " . $response->getStatus());
-		}
-		
-		try {
-			$xml = new SimpleXMLElement($response->getBody());
-		}
-		catch (Exception $e) {
-			var_dump($response->getBody());
-			throw $e;
-		}
-		foreach ($xml->access as $access) {
-			switch ($option) {
-			case 'libraryNotes':
-				if (!isset($access['library'])) {
-					break;
-				}
-				$current = (int) $access['notes'];
-				if ($current != $val) {
-					$access['notes'] = (int) $val;
-					$response = self::put(
-						"users/" . self::$config['userID'] . "/keys/" . self::$config['apiKey'],
-						$xml->asXML(),
-						array(),
-						array(
-							"username" => self::$config['rootUsername'],
-							"password" => self::$config['rootPassword']
-						)
-					);
-					if ($response->getStatus() != 200) {
-						var_dump($response->getBody());
-						throw new Exception("PUT returned " . $response->getStatus());
-					}
-				}
-				break;
-			
-			case 'libraryWrite':
-				if (!isset($access['library'])) {
-					continue;
-				}
-				$current = (int) $access['write'];
-				if ($current != $val) {
-					$access['write'] = (int) $val;
-					$response = self::put(
-						"users/" . self::$config['userID'] . "/keys/" . self::$config['apiKey'],
-						$xml->asXML(),
-						array(),
-						array(
-							"username" => self::$config['rootUsername'],
-							"password" => self::$config['rootPassword']
-						)
-					);
-					if ($response->getStatus() != 200) {
-						var_dump($response->getBody());
-						throw new Exception("PUT returned " . $response->getStatus());
-					}
-				}
-				break;
-			}
-		}
-	}
-	
-	
-	public static function getPluralObjectType($objectType) {
-		if ($objectType == 'search') {
-			return $objectType . "es";
-		}
-		return $objectType . "s";
-	}
-	
-	
-	private static function getObjectXML($objectType, $keys, $context=null) {
-		$objectTypePlural = self::getPluralObjectType($objectType);
-		
-		if (is_scalar($keys)) {
-			$keys = array($keys);
-		}
-		
-		$response = self::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&{$objectType}Key=" . implode(',', $keys) . "&order={$objectType}KeyList"
-				. "&content=json"
-		);
-		if ($context) {
-			$context->assert200($response);
-		}
-		return self::getXMLFromResponse($response);
-	}
-	
-	
-	private static function handleCreateResponse($objectType, $response, $responseFormat, $context=null) {
-		$uctype = ucwords($objectType);
-		
-		if ($context) {
-			$context->assert200($response);
-		}
-		
-		if ($responseFormat == 'response') {
-			return $response;
-		}
-		
-		$json = self::getJSONFromResponse($response);
-		
-		if ($responseFormat != 'responsejson' && sizeOf($json['success']) != 1) {
-			var_dump($json);
-			throw new Exception("$uctype creation failed");
-		}
-		
-		if ($responseFormat == 'responsejson') {
-			return $json;
-		}
-		
-		$key = array_shift($json['success']);
-		
-		if ($responseFormat == 'key') {
-			return $key;
-		}
-		
-		$func = 'get' . $uctype . 'XML';
-		$xml = self::$func($key, $context);
-		
-		if ($responseFormat == 'atom') {
-			return $xml;
-		}
-		
-		$data = self::parseDataFromAtomEntry($xml);
-		
-		if ($responseFormat == 'data') {
-			return $data;
-		}
-		if ($responseFormat == 'content') {
-			return $data['content'];
-		}
-		if ($responseFormat == 'json') {
-			return json_decode($data['content'], true);
-		}
-		
-		throw new Exception("Invalid response format '$responseFormat'");
-	}
-}
-
-API2::loadConfig();
diff --git a/tests/remote/include/api3.inc.php b/tests/remote/include/api3.inc.php
deleted file mode 100644
index d591cef3..00000000
--- a/tests/remote/include/api3.inc.php
+++ /dev/null
@@ -1,1101 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-require_once __DIR__ . '/http.inc.php';
-
-class API3 {
-	private static $config;
-	private static $nsZAPI;
-	private static $apiVersion = false;
-	private static $apiKey = false;
-	
-	public static function loadConfig() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		self::$nsZAPI = 'http://zotero.org/ns/api';
-	}
-	
-	
-	public static function useAPIVersion($apiVersion) {
-		self::$apiVersion = $apiVersion;
-	}
-	
-	
-	public static function useAPIKey($key = "") {
-		self::$apiKey = $key;
-	}
-	
-	
-	public static function createGroup($fields) {
-		$xml = new \SimpleXMLElement('<group/>');
-		$xml['owner'] = $fields['owner'];
-		$xml['name'] = "Test Group " . uniqid();
-		$xml['type'] = $fields['type'];
-		$xml['libraryEditing'] = isset($fields['libraryEditing'])
-			? $fields['libraryEditing']
-			: 'members';
-		$xml['libraryReading'] = isset($fields['libraryReading'])
-			? $fields['libraryReading']
-			: 'members';
-		$xml['fileEditing'] = isset($fields['fileEditing'])
-			? $fields['fileEditing']
-			: 'none';
-		$xml['description'] = "";
-		$xml['url'] = "";
-		$xml['hasImage'] = false;
-		
-		$response = self::superPost(
-			"groups",
-			$xml->asXML()
-		);
-		if ($response->getStatus() != 201) {
-			echo $response->getBody();
-			throw new Exception("Unexpected response code " . $response->getStatus());
-		}
-		$url = $response->getHeader('Location');
-		preg_match('/[0-9]+$/', $url, $matches);
-		return (int) $matches[0];
-	}
-	
-	
-	public static function deleteGroup($groupID) {
-		$response = self::superDelete(
-			"groups/$groupID"
-		);
-		if ($response->getStatus() != 204) {
-			echo $response->getBody();
-			throw new Exception("Unexpected response code " . $response->getStatus());
-		}
-	}
-	
-	
-	public static function createUnsavedDataObject($objectType) {
-		switch ($objectType) {
-		case 'collection':
-			$json = [
-				"name" => "Test"
-			];
-			break;
-		
-		case 'item':
-			// Convert to array
-			$json = json_decode(json_encode(self::getItemTemplate("book")), true);
-			break;
-		
-		case 'search':
-			$json = [
-				"name" => "Test",
-				"conditions" => [
-					[
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					]
-				]
-			];
-			break;
-		}
-		return $json;
-	}
-	
-	
-	public static function createDataObject($objectType, $format) {
-		switch ($objectType) {
-		case 'collection':
-			return self::createCollection("Test", [], null, $format);
-		
-		case 'item':
-			return self::createItem("book", [], null, $format);
-		
-		case 'search':
-			return self::createSearch(
-				"Test",
-				[
-					[
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					]
-				],
-				null,
-				$format
-			);
-		}
-	}
-	
-	
-	//
-	// Item modification methods
-	//
-	public static function getItemTemplate($itemType) {
-		$response = self::get("items/new?itemType=$itemType");
-		if ($response->getStatus() != 200) {
-			var_dump($response->getStatus());
-			var_dump($response->getBody());
-			throw new Exception("Invalid response from template request");
-		}
-		return json_decode($response->getBody());
-	}
-	
-	
-	public static function createItem($itemType, $data=array(), $context=null, $returnFormat='responseJSON') {
-		$json = self::getItemTemplate($itemType);
-		
-		if ($data) {
-			foreach ($data as $field => $val) {
-				$json->$field = $val;
-			}
-		}
-		
-		$response = self::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			[
-				"Content-Type: application/json",
-				"Zotero-API-Key: " . self::$config['apiKey']
-			]
-		);
-		
-		return self::handleCreateResponse('item', $response, $returnFormat, $context);
-	}
-	
-	
-	/**
-	 * POST a JSON item object to the main test user's account
-	 * and return the response
-	 */
-	public static function postItem($json) {
-		return self::postItems(array($json));
-	}
-	
-	
-	/**
-	 * POST a JSON items object to the main test user's account
-	 * and return the response
-	 */
-	public static function postItems($json) {
-		return self::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-	}
-	
-	
-	public static function groupCreateItem($groupID, $itemType, $data=[], $context=null, $returnFormat='responseJSON') {
-		$response = self::get("items/new?itemType=$itemType");
-		$json = json_decode($response->getBody());
-		
-		if ($data) {
-			foreach ($data as $field => $val) {
-				$json->$field = $val;
-			}
-		}
-		
-		$response = self::groupPost(
-			$groupID,
-			"items?key=" . self::$config['apiKey'],
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		return self::handleCreateResponse('item', $response, $returnFormat, $context, $groupID);
-	}
-	
-	
-	public static function createAttachmentItem($linkMode, $data=[], $parentKey=false, $context=false, $returnFormat='responseJSON') {
-		$response = self::get("items/new?itemType=attachment&linkMode=$linkMode");
-		$json = json_decode($response->getBody());
-		foreach ($data as $key => $val) {
-			$json->{$key} = $val;
-		}
-		if ($parentKey) {
-			$json->parentItem = $parentKey;
-		}
-		
-		$response = self::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		
-		return self::handleCreateResponse('item', $response, $returnFormat, $context); 
-	}
-	
-	
-	public static function groupCreateAttachmentItem($groupID, $linkMode, $data=[], $parentKey=false, $context=false, $returnFormat='responseJSON') {
-		$response = self::get("items/new?itemType=attachment&linkMode=$linkMode");
-		$json = json_decode($response->getBody());
-		foreach ($data as $key => $val) {
-			$json->{$key} = $val;
-		}
-		if ($parentKey) {
-			$json->parentItem = $parentKey;
-		}
-		
-		$response = self::groupPost(
-			$groupID,
-			"items?key=" . self::$config['apiKey'],
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		
-		return self::handleCreateResponse('item', $response, $returnFormat, $context, $groupID);
-	}
-	
-	
-	public static function createNoteItem($text="", $parentKey=false, $context=false, $returnFormat='responseJSON') {
-		$response = self::get("items/new?itemType=note");
-		$json = json_decode($response->getBody());
-		$json->note = $text;
-		if ($parentKey) {
-			$json->parentItem = $parentKey;
-		}
-		
-		$response = self::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		return self::handleCreateResponse('item', $response, $returnFormat, $context);
-	}
-	
-	
-	public static function createCollection($name, $data=array(), $context=null, $returnFormat='responseJSON') {
-		if (is_array($data)) {
-			$parent = isset($data['parentCollection']) ? $data['parentCollection'] : false;
-			$relations = isset($data['relations']) ? $data['relations'] : new stdClass;
-		}
-		else {
-			$parent = $data ? $data : false;
-			$relations = new stdClass;
-		}
-		
-		$json = [
-			[
-				'name' => $name,
-				'parentCollection' => $parent,
-				'relations' => $relations
-			]
-		];
-		
-		$response = self::userPost(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		
-		return self::handleCreateResponse('collection', $response, $returnFormat, $context);
-	}
-	
-	
-	public static function createSearch($name, $conditions=array(), $context=null, $returnFormat='responseJSON') {
-		if ($conditions == 'default') {
-			$conditions = array(
-				array(
-					'condition' => 'title',
-					'operator' => 'contains',
-					'value' => 'test'
-				)
-			);
-		}
-		
-		$json = [
-			[
-				'name' => $name,
-				'conditions' => $conditions
-			]
-		];
-		
-		$response = self::userPost(
-			self::$config['userID'],
-			"searches?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		
-		return self::handleCreateResponse('search', $response, $returnFormat, $context);
-	}
-	
-	
-	public static function getLibraryVersion() {
-		$response = self::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&limit=1"
-		);
-		return $response->getHeader("Last-Modified-Version");
-	}
-	
-	
-	public static function getGroupLibraryVersion($groupID) {
-		$response = self::groupGet(
-			$groupID,
-			"items?key=" . self::$config['apiKey'] . "&format=keys&limit=1"
-		);
-		return $response->getHeader("Last-Modified-Version");
-	}
-	
-	
-	public static function getItem($keys, $context=null, $format=false, $groupID=false) {
-		return self::getObject('item', $keys, $context, $format, $groupID);
-	}
-	
-	
-	public static function getItemResponse($keys, $context=null, $format=false, $groupID=false) {
-		return self::getObjectResponse('item', $keys, $format, $context, $groupID);
-	}
-	
-	
-	public static function getCollection($keys, $context=null, $format=false, $groupID=false) {
-		return self::getObject('collection', $keys, $context, $format, $groupID);
-	}
-	
-	public static function getCollectionResponse($keys, $context=null, $format=false, $groupID=false) {
-		return self::getObjectResponse('collection', $keys, $context, $format, $groupID);
-	}
-	
-	
-	public static function getSearch($keys, $context=null, $format=false, $groupID=false) {
-		return self::getObject('search', $keys, $context, $format, $groupID);
-	}
-	
-	
-	public static function getSearchResponse($keys, $context=null, $format=false, $groupID=false) {
-		return self::getObjectResponse('search', $keys, $context, $format, $groupID);
-	}
-	
-	
-	// Atom
-	public static function getItemXML($keys, $context=null) {
-		return self::getObject('item', $keys, $context, 'atom');
-	}
-	
-	
-	public static function getCollectionXML($keys, $context=null) {
-		return self::getObject('collection', $keys, $context, 'atom');
-	}
-	
-	
-	public static function getSearchXML($keys, $context=null) {
-		return self::getObject('search', $keys, $context, 'atom');
-	}
-	
-	
-	public static function groupGetItemXML($groupID, $keys, $context=null) {
-		if (is_scalar($keys)) {
-			$keys = array($keys);
-		}
-		
-		$response = self::groupGet(
-			$groupID,
-			"items?key=" . self::$config['apiKey']
-				. "&itemKey=" . implode(',', $keys) . "&order=itemKeyList"
-				. "&content=json"
-		);
-		if ($context) {
-			$context->assert200($response);
-		}
-		return self::getXMLFromResponse($response);
-	}
-	
-	
-	public static function getXMLFromFirstSuccessItem($response) {
-		$key = self::getFirstSuccessKeyFromResponse($response);
-		self::getItemXML($key);
-	}
-	
-	
-	
-	
-	//
-	// HTTP methods
-	//
-	public static function get($url, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		if (!$auth && self::$apiKey) {
-			$headers[] = "Authorization: Bearer " . self::$apiKey;
-		}
-		$response = HTTP::get($url, $headers, $auth);
-		if (self::$config['verbose'] >= 2) {
-			echo "\n\n" . $response->getBody() . "\n";
-		}
-		return $response;
-	}
-	
-	public static function superGet($url, $headers=[]) {
-		return self::get(
-			$url,
-			$headers,
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-	}
-	
-	public static function userGet($userID, $suffix, $headers=array(), $auth=false) {
-		return self::get("users/$userID/$suffix", $headers, $auth);
-	}
-	
-	public static function groupGet($groupID, $suffix, $headers=array(), $auth=false) {
-		return self::get("groups/$groupID/$suffix", $headers, $auth);
-	}
-	
-	public static function post($url, $data, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		if (!$auth && self::$apiKey) {
-			$headers[] = "Authorization: Bearer " . self::$apiKey;
-		}
-		$response = HTTP::post($url, $data, $headers, $auth);
-		return $response;
-	}
-	
-	public static function superPost($url, $data, $headers=[]) {
-		return self::post(
-			$url,
-			$data,
-			$headers,
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-	}
-	
-	public static function userPost($userID, $suffix, $data, $headers=array(), $auth=false) {
-		return self::post("users/$userID/$suffix", $data, $headers, $auth);
-	}
-	
-	public static function groupPost($groupID, $suffix, $data, $headers=array(), $auth=false) {
-		return self::post("groups/$groupID/$suffix", $data, $headers, $auth);
-	}
-	
-	public static function put($url, $data, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		if (!$auth && self::$apiKey) {
-			$headers[] = "Authorization: Bearer " . self::$apiKey;
-		}
-		$response = HTTP::put($url, $data, $headers, $auth);
-		return $response;
-	}
-	
-	public static function superPut($url, $data, $headers=[]) {
-		return self::put(
-			$url,
-			$data,
-			$headers,
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-	}
-	
-	public static function userPut($userID, $suffix, $data, $headers=array(), $auth=false) {
-		return self::put("users/$userID/$suffix", $data, $headers, $auth);
-	}
-	
-	public static function groupPut($groupID, $suffix, $data, $headers=array(), $auth=false) {
-		return self::put("groups/$groupID/$suffix", $data, $headers, $auth);
-	}
-	
-	public static function patch($url, $data, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		if (!$auth && self::$apiKey) {
-			$headers[] = "Authorization: Bearer " . self::$apiKey;
-		}
-		$response = HTTP::patch($url, $data, $headers, $auth);
-		return $response;
-	}
-	
-	public static function userPatch($userID, $suffix, $data, $headers=array()) {
-		return self::patch("users/$userID/$suffix", $data, $headers);
-	}
-	
-	public static function head($url, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		if (!$auth && self::$apiKey) {
-			$headers[] = "Authorization: Bearer " . self::$apiKey;
-		}
-		$response = HTTP::head($url, $headers, $auth);
-		return $response;
-	}
-	
-	public static function userHead($userID, $suffix, $headers=array(), $auth=false) {
-		return self::head("users/$userID/$suffix", $headers, $auth);
-	}
-	
-	public static function delete($url, $headers=array(), $auth=false) {
-		$url = self::$config['apiURLPrefix'] . $url;
-		if (self::$apiVersion) {
-			$headers[] = "Zotero-API-Version: " . self::$apiVersion;
-		}
-		if (!$auth && self::$apiKey) {
-			$headers[] = "Authorization: Bearer " . self::$apiKey;
-		}
-		$response = HTTP::delete($url, $headers, $auth);
-		return $response;
-	}
-	
-	public static function superDelete($url, $headers=[]) {
-		return self::delete(
-			$url,
-			$headers,
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-	}
-	
-	public static function userDelete($userID, $suffix, $headers=array(), $auth=false) {
-		return self::delete("users/$userID/$suffix", $headers, $auth);
-	}
-	
-	public static function groupDelete($groupID, $suffix, $headers=array(), $auth=false) {
-		return self::delete("groups/$groupID/$suffix", $headers, $auth);
-	}
-	
-	
-	public static function userClear($userID) {
-		$response = self::userPost(
-			$userID,
-			"clear",
-			"",
-			array(),
-			array(
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			)
-		);
-		if ($response->getStatus() != 204) {
-			var_dump($response->getBody());
-			throw new Exception("Error clearing user $userID");
-		}
-	}
-	
-	public static function groupClear($groupID) {
-		$response = self::groupPost(
-			$groupID,
-			"clear",
-			"",
-			array(),
-			array(
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			)
-		);
-		if ($response->getStatus() != 204) {
-			var_dump($response->getBody());
-			throw new Exception("Error clearing group $groupID");
-		}
-	}
-	
-	
-	//
-	// Response parsing
-	//
-	public static function getXMLFromResponse($response) {
-		try {
-			$xml = new SimpleXMLElement($response->getBody());
-		}
-		catch (Exception $e) {
-			var_dump($response->getBody());
-			throw $e;
-		}
-		$xml->registerXPathNamespace('atom', 'http://www.w3.org/2005/Atom');
-		$xml->registerXPathNamespace('zapi', 'http://zotero.org/ns/api');
-		return $xml;
-	}
-	
-	
-	public static function getJSONFromResponse($response, $asObject=false) {
-		$json = json_decode($response->getBody(), !$asObject);
-		if (is_null($json)) {
-			var_dump($response->getBody());
-			throw new Exception("JSON response could not be parsed");
-		}
-		return $json;
-	}
-	
-	
-	public static function getFirstSuccessKeyFromResponse($response) {
-		$json = self::getJSONFromResponse($response);
-		if (empty($json['success'])) {
-			var_dump($response->getBody());
-			throw new Exception("No success keys found in response");
-		}
-		return array_shift($json['success']);
-	}
-	
-	public static function getSuccessfulKeysFromResponse($response) {
-		$json = self::getJSONFromResponse($response);
-		return array_map(function ($o) { return $o['key']; }, $json['successful']);
-	}
-	
-	
-	public static function parseDataFromAtomEntry($entryXML) {
-		$key = (string) array_get_first($entryXML->xpath('//atom:entry/zapi:key'));
-		$version = (string) array_get_first($entryXML->xpath('//atom:entry/zapi:version'));
-		$content = array_get_first($entryXML->xpath('//atom:entry/atom:content'));
-		if (is_null($content)) {
-			var_dump($entryXML->asXML());
-			throw new Exception("Atom response does not contain <content>");
-		}
-		
-		// If 'content' contains XML, serialize all subnodes
-		if ($content->count()) {
-			$content = $content->asXML();
-		}
-		// Otherwise just get string content
-		else {
-			$content = (string) $content;
-		}
-		
-		return array(
-			"key" => $key,
-			"version" => $version,
-			"content" => $content
-		);
-	}
-	
-	
-	public static function getContentFromResponse($response) {
-		$xml = self::getXMLFromResponse($response);
-		$data = self::parseDataFromAtomEntry($xml);
-		return $data['content'];
-	}
-	
-	
-	/**
-	 * @return mixed Array (JSON) or SimpleXMLElement (HTML)
-	 */
-	public static function getContentFromAtomResponse($response, $type = null) {
-		$xml = self::getXMLFromResponse($response);
-		$content = array_get_first($xml->xpath('//atom:entry/atom:content'));
-		if (is_null($content)) {
-			var_dump($xml->asXML());
-			throw new Exception("Atom response does not contain <content>");
-		}
-		
-		$subcontent = array_get_first($content->xpath('//zapi:subcontent'));
-		if ($subcontent) {
-			if (!$type) {
-				throw new Exception('$type not provided for multi-content response');
-			}
-			switch ($type) {
-			case 'json':
-				return json_decode((string) $subcontent[0]->xpath('//zapi:subcontent[@zapi:type="json"]')[0], true);
-			
-			case 'html':
-				$html = array_get_first($subcontent[0]->xpath('//zapi:subcontent[@zapi:type="html"]'));
-				$html->registerXPathNamespace('html', 'http://www.w3.org/1999/xhtml');
-				return $html;
-				
-			default:
-				throw new Exception("Unknown data type '$type'");
-			}
-		}
-		else {
-			throw new Exception("Unimplemented");
-		}
-	}
-	
-	
-	public static function parseLinkHeader($response) {
-		$header = $response->getHeader('Link');
-		$links = [];
-		foreach (explode(',', $header) as $val) {
-			preg_match('#<([^>]+)>; rel="([^"]+)"#', $val, $matches);
-			$links[$matches[2]] = $matches[1];
-		}
-		return $links;
-	}
-	
-	
-	public static function resetKey($key) {
-		$response = self::get(
-			"keys/$key",
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		if ($response->getStatus() != 200) {
-			var_dump($response->getBody());
-			throw new Exception("GET returned " . $response->getStatus());
-		}
-		
-		$json = self::getJSONFromResponse($response, true);
-		
-		$resetLibrary = function ($lib) {
-			foreach ($lib as $permission => $value) {
-				$lib->$permission = false;
-			}
-		};
-		// Remove all individual library permissions and remove groups section
-		if (isset($json->access->user)) {
-			$resetLibrary($json->access->user);
-		}
-		unset($json->access->groups);
-		
-		$response = self::put(
-			"users/" . self::$config['userID'] . "/keys/" . self::$config['apiKey'],
-			json_encode($json),
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		if ($response->getStatus() != 200) {
-			var_dump($response->getBody());
-			throw new Exception("PUT returned " . $response->getStatus());
-		}
-	}
-	
-	
-	/**
-	 * @deprecated
-	 */
-	public static function setKeyOption($userID, $key, $option, $val) {
-		error_log("setKeyOption() is deprecated -- use setKeyUserPermission()");
-		
-		switch ($option) {
-			case 'libraryNotes':
-				$option = 'notes';
-				break;
-				
-			case 'libraryWrite':
-				$option = 'write';
-				break;
-		}
-		
-		self::setKeyUserPermission($key, $option, $val);
-	}
-	
-	
-	public static function setKeyUserPermission($key, $permission, $value) {
-		$response = self::get(
-			"keys/$key",
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		if ($response->getStatus() != 200) {
-			var_dump($response->getBody());
-			throw new Exception("GET returned " . $response->getStatus());
-		}
-		
-		if (self::$apiVersion >= 3) {
-			$json = self::getJSONFromResponse($response);
-			
-			switch ($permission) {
-			case 'library':
-				if (isset($json['access']['user']) && $value == !empty($json['access']['user']['library'])) {
-					break;
-				}
-				$json['access']['user']['library'] = $value;
-				break;
-			
-			case 'write':
-				if (isset($json['access']['user']) && $value == !empty($json['access']['user']['write'])) {
-					break;
-				}
-				$json['access']['user']['write'] = $value;
-				break;
-			
-			case 'notes':
-				if (isset($json['access']['user']) && $value == !empty($json['access']['user']['notes'])) {
-					break;
-				}
-				$json['access']['user']['notes'] = $value;
-				break;
-			}
-			
-			$response = self::put(
-				"keys/" . self::$config['apiKey'],
-				json_encode($json),
-				[],
-				[
-					"username" => self::$config['rootUsername'],
-					"password" => self::$config['rootPassword']
-				]
-			);
-		}
-		else {
-			try {
-				$xml = new SimpleXMLElement($response->getBody());
-			}
-			catch (Exception $e) {
-				var_dump($response->getBody());
-				throw $e;
-			}
-			foreach ($xml->access as $access) {
-				switch ($permission) {
-				case 'library':
-					$current = (int) $access['library'];
-					if ($current != $value) {
-						$access['library'] = (int) $value;
-					}
-					break;
-				
-				case 'write':
-					if (!isset($access['library'])) {
-						continue;
-					}
-					$current = (int) $access['write'];
-					if ($current != $value) {
-						$access['write'] = (int) $value;
-					}
-					break;
-				
-				case 'notes':
-					if (!isset($access['library'])) {
-						break;
-					}
-					$current = (int) $access['notes'];
-					if ($current != $value) {
-						$access['notes'] = (int) $value;
-					}
-					break;
-				}
-			}
-			
-			$response = self::put(
-				"keys/" . self::$config['apiKey'],
-				$xml->asXML(),
-				[],
-				[
-					"username" => self::$config['rootUsername'],
-					"password" => self::$config['rootPassword']
-				]
-			);
-		}
-		if ($response->getStatus() != 200) {
-			var_dump($response->getBody());
-			throw new Exception("PUT returned " . $response->getStatus());
-		}
-	}
-	
-	
-	public static function setKeyGroupPermission($key, $groupID, $permission, $value) {
-		$response = self::get(
-			"keys/$key",
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		if ($response->getStatus() != 200) {
-			var_dump($response->getBody());
-			throw new Exception("GET returned " . $response->getStatus());
-		}
-		
-		$json = self::getJSONFromResponse($response);
-		if (!isset($json['access'])) {
-			$json['access'] = [];
-		}
-		if (!isset($json['access']['groups'])) {
-			$json['access']['groups'] = [];
-		}
-		$json['access']['groups'][$groupID][$permission] = true;
-		$response = self::put(
-			"keys/" . $key,
-			json_encode($json),
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		if ($response->getStatus() != 200) {
-			var_dump($response->getBody());
-			throw new Exception("PUT returned " . $response->getStatus());
-		}
-	}
-	
-	
-	public static function getPluralObjectType($objectType) {
-		if ($objectType == 'search') {
-			return $objectType . "es";
-		}
-		return $objectType . "s";
-	}
-	
-	
-	private static function getObjectResponse($objectType, $keys, $context=null, $format=false, $groupID=false) {
-		$objectTypePlural = self::getPluralObjectType($objectType);
-		
-		$single = is_string($keys);
-		
-		$url = "$objectTypePlural";
-		if ($single) {
-			$url .= "/$keys";
-		}
-		$url .= "?key=" . self::$config['apiKey'];
-		if (!$single) {
-			$url .= "&{$objectType}Key=" . implode(',', $keys) . "&order={$objectType}KeyList";
-		}
-		if ($format !== false) {
-			$url .= "&format=" . $format;
-			if ($format == 'atom') {
-				$url .= '&content=json';
-			}
-		}
-		if ($groupID) {
-			$response = self::groupGet($groupID, $url);
-		}
-		else {
-			$response = self::userGet(self::$config['userID'], $url);
-		}
-		if ($context) {
-			$context->assert200($response);
-		}
-		return $response;
-	}
-	
-	
-	private static function getObject($objectType, $keys, $context=null, $format=false, $groupID=false) {
-		$response = self::getObjectResponse($objectType, $keys, $context, $format, $groupID);
-		$contentType = $response->getHeader('Content-Type');
-		switch ($contentType) {
-		case 'application/json':
-			return self::getJSONFromResponse($response);
-		
-		case 'application/atom+xml':
-			return self::getXMLFromResponse($response);
-		
-		default:
-			var_dump($response->getBody());
-			throw new Exception("Unknown content type '$contentType'");
-		}
-	}
-	
-	
-	private static function handleCreateResponse($objectType, $response, $returnFormat, $context=null, $groupID=false) {
-		if ($context) {
-			if (!preg_match('/APIv([0-9]+)/', get_class($context), $matches)) {
-				throw new Exception("Unexpected namespace");
-			}
-			$apiVersion = (int) $matches[1];
-		}
-		
-		$uctype = ucwords($objectType);
-		
-		if ($context) {
-			$context->assert200($response);
-		}
-		
-		if ($returnFormat == 'response') {
-			return $response;
-		}
-		
-		$json = self::getJSONFromResponse($response);
-		
-		if ($returnFormat != 'responseJSON' && sizeOf($json['success']) != 1) {
-			var_dump($json);
-			throw new Exception("$uctype creation failed");
-		}
-		
-		if ($returnFormat == 'responseJSON') {
-			return $json;
-		}
-		
-		$key = array_shift($json['success']);
-		
-		if ($returnFormat == 'key') {
-			return $key;
-		}
-		
-		// returnFormat can be 'json', 'jsonResponse', 'atom', 'atomResponse', 'content', 'data'
-		$asResponse = false;
-		if (preg_match('/response$/i', $returnFormat)) {
-			$returnFormat = substr($returnFormat, 0, -8);
-			$asResponse = true;
-		}
-		$func = 'get' . $uctype . ($asResponse ? 'Response' : '');
-		
-		if (substr($returnFormat, 0, 4) == 'json') {
-			$response = self::$func($key, $context, 'json', $groupID);
-			if ($returnFormat == 'json' || $returnFormat == 'jsonResponse') {
-				return $response;
-			}
-			if ($returnFormat == 'jsonData') {
-				return $response['data'];
-			}
-		}
-		
-		// Request Atom
-		$response = self::$func($key, $context, 'atom', $groupID);
-		
-		if ($returnFormat == 'atom' || $returnFormat == 'atomResponse') {
-			return $response;
-		}
-		
-		$xml = self::getXMLFromResponse($response);
-		$data = self::parseDataFromAtomEntry($xml);
-		
-		if ($returnFormat == 'data') {
-			return $data;
-		}
-		if ($returnFormat == 'content') {
-			return $data['content'];
-		}
-		if ($returnFormat == 'atomJSON') {
-			return json_decode($data['content'], true);
-		}
-		
-		throw new Exception("Invalid result format '$returnFormat'");
-	}
-}
-
-API3::loadConfig();
diff --git a/tests/remote/include/bootstrap.inc.php b/tests/remote/include/bootstrap.inc.php
deleted file mode 100644
index de8398e1..00000000
--- a/tests/remote/include/bootstrap.inc.php
+++ /dev/null
@@ -1,64 +0,0 @@
-<?php
-require '../../vendor/autoload.php';
-require 'include/config.inc.php';
-
-mb_language('uni');
-mb_internal_encoding('UTF-8');
-date_default_timezone_set('UTC');
-require '../../model/Date.inc.php';
-require '../../model/Utilities.inc.php';
-
-class Z_Tests {
-	public static $AWS;
-}
-
-//
-// Set up AWS service factory
-//
-$awsConfig = [
-	'region' => $config['awsRegion'],
-	'version' => 'latest'
-];
-//  Access key and secret (otherwise uses IAM role authentication)
-if (!empty($config['awsAccessKey'])) {
-	$awsConfig['credentials'] = [
-		'key' => $config['awsAccessKey'],
-		'secret' => $config['awsSecretKey']
-	];
-}
-Z_Tests::$AWS = new Aws\Sdk($awsConfig);
-unset($awsConfig);
-
-// Wipe data and create API key
-require_once 'http.inc.php';
-$response = HTTP::post(
-	$config['apiURLPrefix'] . "test/setup?u=" . $config['userID'],
-	" ",
-	[],
-	[
-		"username" => $config['rootUsername'],
-		"password" => $config['rootPassword']
-	]
-);
-$json = json_decode($response->getBody());
-if (!$json) {
-	echo $response->getStatus() . "\n\n";
-	echo $response->getBody();
-	throw new Exception("Invalid test setup response");
-}
-$config['apiKey'] = $json->apiKey;
-\Zotero\Tests\Config::update($config);
-
-// Set up groups
-require 'groups.inc.php';
-
-/**
- * @param $arr
- * @return mixed
- */
-function array_get_first($arr) {
-	if (is_array($arr) && isset($arr[0])) {
-		return $arr[0];
-	}
-	return null;
-}
diff --git a/tests/remote/include/config.inc.php b/tests/remote/include/config.inc.php
deleted file mode 100644
index 23a1459e..00000000
--- a/tests/remote/include/config.inc.php
+++ /dev/null
@@ -1,38 +0,0 @@
-<?
-namespace Zotero\Tests;
-
-if (!class_exists('Zotero\Tests\Config')) {
-	class Config {
-		private static $instance;
-		private static $config;
-		
-		protected function __construct() {
-			self::$config = parse_ini_file('config.ini');
-		}
-		
-		private static function getInstance() {
-			if (!isset(self::$instance)) {
-				self::$instance = new static();
-			}
-			return self::$instance;
-		}
-		
-		public static function getConfig() {
-			 $instance = self::getInstance();
-			 return $instance::$config;
-		}
-		
-		public static function update($config) {
-			self::getInstance();
-			
-			foreach ($config as $key => $val) {
-				if (!isset(self::$config[$key]) || self::$config[$key] !== $val) {
-					self::$config[$key] = $val;
-				}
-			}
-		}
-	}
-}
-
-$config = Config::getConfig();
-?>
diff --git a/tests/remote/include/config.ini-sample b/tests/remote/include/config.ini-sample
deleted file mode 100644
index e02e5dd5..00000000
--- a/tests/remote/include/config.ini-sample
+++ /dev/null
@@ -1,31 +0,0 @@
-; 0 => off, 1 => HTTP requests, 2 => HTTP request/response bodies
-verbose = 0
-syncURLPrefix = ""
-apiURLPrefix = ""
-rootUsername = ""
-rootPassword = ""
-awsRegion = "us-east-1"
-s3Bucket = ""
-; Leave credentials empty to use IAM role
-awsAccessKey = ""
-awsSecretKey = ""
-syncVersion = 9
-
-[user1]
-userID = 0
-libraryID = 0
-username = "phpunit"
-password = ""
-; Should have libraryEditing='members'
-ownedPrivateGroupID = 0
-ownedPrivateGroupLibraryID = 0
-ownedPrivateGroupName = "Private Test Group"
-
-; Should be in user 1's private group
-[user2]
-userID2 = 0
-username2 = "phpunit2"
-password2 = ""
-; User 1 should not be a member
-ownedPrivateGroupID2 = 0
-ownedPrivateGroupLibraryID2 = 0
diff --git a/tests/remote/include/groups.inc.php b/tests/remote/include/groups.inc.php
deleted file mode 100644
index f372d6e9..00000000
--- a/tests/remote/include/groups.inc.php
+++ /dev/null
@@ -1,73 +0,0 @@
-<?php
-//
-// Check for existing groups, make sure they have the right permissions,
-// and delete any others
-//
-require_once __DIR__ . '/api3.inc.php';
-$response = API3::superGet(
-	"users/" . $config['userID'] . "/groups"
-);
-$groups = API3::getJSONFromResponse($response);
-$config['ownedPublicGroupID'] = false;
-$config['ownedPublicNoAnonymousGroupID'] = false;
-$toDelete = [];
-foreach ($groups as $group) {
-	$data = $group['data'];
-	$id = $data['id'];
-	$type = $data['type'];
-	$owner = $data['owner'];
-	$libraryReading = $data['libraryReading'];
-	
-	if (!$config['ownedPublicGroupID']
-			&& $type == 'PublicOpen'
-			&& $owner == $config['userID']
-			&& $libraryReading == 'all') {
-		$config['ownedPublicGroupID'] = $id;
-	}
-	else if (!$config['ownedPublicNoAnonymousGroupID']
-			&& $type == 'PublicClosed'
-			&& $owner == $config['userID']
-			&& $libraryReading == 'members') {
-		$config['ownedPublicNoAnonymousGroupID'] = $id;
-	}
-	else if ($type == 'Private'
-			&& ($id == $config['ownedPrivateGroupID'] || $id == $config['ownedPrivateGroupID2'])) {
-		continue;
-	}
-	else {
-		$toDelete[] = $id;
-	}
-}
-
-if (!$config['ownedPublicGroupID']) {
-	$config['ownedPublicGroupID'] = API3::createGroup([
-		'owner' => $config['userID'],
-		'type' => 'PublicOpen',
-		'libraryReading' => 'all'
-	]);
-}
-if (!$config['ownedPublicNoAnonymousGroupID']) {
-	$config['ownedPublicNoAnonymousGroupID'] = API3::createGroup([
-		'owner' => $config['userID'],
-		'type' => 'PublicClosed',
-		'libraryReading' => 'members'
-	]);
-}
-foreach ($toDelete as $groupID) {
-	API3::deleteGroup($groupID);
-}
-
-$config['numOwnedGroups'] = 3;
-$config['numPublicGroups'] = 2;
-
-foreach ($groups as $group) {
-	if (!in_array($group['id'], $toDelete)) {
-		API3::groupClear($group['id']);
-	}
-}
-
-\Zotero\Tests\Config::update($config);
-
-unset($response);
-unset($groups);
-unset($toDelete);
diff --git a/tests/remote/include/http.inc.php b/tests/remote/include/http.inc.php
deleted file mode 100644
index 16246749..00000000
--- a/tests/remote/include/http.inc.php
+++ /dev/null
@@ -1,141 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-require_once 'HTTP/Request2.php';
-
-class HTTP {
-	private static $config;
-	
-	private static function loadConfig() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-	}
-	
-	
-	private static function getRequest($url, $headers, $auth) {
-		$req = new HTTP_Request2($url);
-		$req->setHeader($headers);
-		if ($auth) {
-			$req->setAuth($auth['username'], $auth['password']);
-		}
-		$req->setHeader("Expect:");
-		$req->setConfig([
-			'ssl_verify_peer' => false,
-			'ssl_verify_host' => false
-		]);
-		
-		return $req;
-	}
-	
-	private static function sendRequest($req) {
-		return $req->send();
-	}
-	
-	
-	public static function get($url, $headers=array(), $auth=false) {
-		self::loadConfig();
-		$req = self::getRequest($url, $headers, $auth);
-		if (self::$config['verbose'] >= 1) {
-			echo "\nGET $url\n";
-		}
-		$response = self::sendRequest($req);
-		if (self::$config['verbose'] >= 2) {
-			echo "\n\n" . $response->getBody() . "\n";
-		}
-		return $response;
-	}
-	
-	public static function post($url, $data, $headers=array(), $auth=false) {
-		$req = self::getRequest($url, $headers, $auth);
-		$req->setMethod(HTTP_Request2::METHOD_POST);
-		if (is_array($data)) {
-			$req->addPostParameter($data);
-		}
-		else {
-			$req->setBody($data);
-		}
-		if (self::$config['verbose'] >= 1) {
-			echo "\nPOST $url\n";
-		}
-		$response = self::sendRequest($req);
-		return $response;
-	}
-	
-	public static function put($url, $data, $headers=array(), $auth=false) {
-		$req = self::getRequest($url, $headers, $auth);
-		$req->setMethod(HTTP_Request2::METHOD_PUT);
-		$req->setBody($data);
-		if (self::$config['verbose'] >= 1) {
-			echo "\nPUT $url\n";
-		}
-		$response = self::sendRequest($req);
-		return $response;
-	}
-	
-	public static function patch($url, $data, $headers=array(), $auth=false) {
-		$req = self::getRequest($url, $headers, $auth);
-		$req->setMethod("PATCH");
-		$req->setBody($data);
-		if (self::$config['verbose'] >= 1) {
-			echo "\nPATCH $url\n";
-		}
-		$response = self::sendRequest($req);
-		return $response;
-	}
-	
-	public static function head($url, $headers=array(), $auth=false) {
-		$req = self::getRequest($url, $headers, $auth);
-		$req->setMethod(HTTP_Request2::METHOD_HEAD);
-		if (self::$config['verbose'] >= 1) {
-			echo "\nHEAD $url\n";
-		}
-		$response = self::sendRequest($req);
-		return $response;
-	}
-	
-	
-	public static function options($url, $headers=[], $auth=false) {
-		$req = self::getRequest($url, $headers, $auth);
-		$req->setMethod(HTTP_Request2::METHOD_OPTIONS);
-		if (self::$config['verbose'] >= 1) {
-			echo "\nOPTIONS $url\n";
-		}
-		$response = self::sendRequest($req);
-		return $response;
-	}
-	
-	public static function delete($url, $headers=array(), $auth=false) {
-		$req = self::getRequest($url, $headers, $auth);
-		$req->setMethod(HTTP_Request2::METHOD_DELETE);
-		if (self::$config['verbose'] >= 1) {
-			echo "\nDELETE $url\n";
-		}
-		$response = self::sendRequest($req);
-		return $response;
-	}
-}
diff --git a/tests/remote/include/sync.inc.php b/tests/remote/include/sync.inc.php
deleted file mode 100644
index 7d6851f3..00000000
--- a/tests/remote/include/sync.inc.php
+++ /dev/null
@@ -1,401 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-require_once 'include/bootstrap.inc.php';
-require_once '../../model/Utilities.inc.php';
-
-class Sync {
-	private static $config;
-	
-	public static function loadConfig() {
-		if (self::$config) {
-			return;
-		}
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		date_default_timezone_set('UTC');
-	}
-	
-	
-	public static function useZoteroVersion($version=false) {
-		if ($version) {
-			self::$config['zoteroVersion'] = $version;
-		}
-		else {
-			self::$config['zoteroVersion'] = null;
-		}
-	}
-	
-	
-	public static function createItem($sessionID, $libraryID, $itemType, $data=array(), $context) {
-		$xml = Sync::updated($sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$key = Zotero_Utilities::randomString(8, 'key', true);
-		$dateAdded = date( 'Y-m-d H:i:s', time() - 1);
-		$dateModified = date( 'Y-m-d H:i:s', time());
-		
-		$xmlstr = '<data version="9">'
-			. '<items>'
-			. '<item libraryID="' . $libraryID . '" '
-				. 'itemType="' . $itemType . '" '
-				. 'dateAdded="' . $dateAdded . '" '
-				. 'dateModified="' . $dateModified . '" '
-				. 'key="' . $key . '">';
-		if ($data) {
-			$relatedstr = "";
-			foreach ($data as $field => $val) {
-				$xmlstr .= '<field name="' . $field . '">' . $val . '</field>';
-				if ($key == 'related') {
-					$relatedstr .= "<related>$val</related>";
-				}
-			}
-			$xmlstr .= $relatedstr;
-		}
-		$xmlstr .= '</item>'
-			. '</items>'
-			. '</data>';
-		$response = Sync::upload($sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload($sessionID, $response, $context);
-		
-		return $key;
-	}
-	
-	
-	public static function deleteItem($sessionID, $libraryID, $itemKey, $context=null) {
-		$xml = Sync::updated($sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$xmlstr = '<data version="9">'
-			. '<deleted>'
-			. '<items>'
-			. '<item libraryID="' . self::$config['libraryID']
-				. '" key="' . $itemKey . '"/>'
-			. '</items>'
-			. '</deleted>'
-			. '</data>';
-		$response = Sync::upload($sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload($sessionID, $response, $context);
-	}
-	
-	
-	public static function createCollection($sessionID, $libraryID, $name, $parent, $context) {
-		$xml = Sync::updated($sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$key = Zotero_Utilities::randomString(8, 'key', true);
-		$dateAdded = date( 'Y-m-d H:i:s', time() - 1);
-		$dateModified = date( 'Y-m-d H:i:s', time());
-		
-		$xmlstr = '<data version="9">'
-			. '<collections>'
-			. '<collection libraryID="' . $libraryID . '" '
-				. 'name="' . $name . '" ';
-		if ($parent) {
-			$xmlstr .= 'parent="' . $name . '" ';
-		}
-		$xmlstr .= 'dateAdded="' . $dateAdded . '" '
-				. 'dateModified="' . $dateModified . '" '
-				. 'key="' . $key . '"/>'
-			. '</collections>'
-			. '</data>';
-		$response = Sync::upload($sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload($sessionID, $response, $context);
-		
-		return $key;
-	}
-	
-	
-	public static function createSearch($sessionID, $libraryID, $name, $conditions, $context) {
-		if ($conditions == 'default') {
-			$conditions = array(
-				array(
-					'condition' => 'title',
-					'operator' => 'contains',
-					'value' => 'test'
-				)
-			);
-		}
-		
-		$xml = Sync::updated($sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$key = Zotero_Utilities::randomString(8, 'key', true);
-		$dateAdded = date( 'Y-m-d H:i:s', time() - 1);
-		$dateModified = date( 'Y-m-d H:i:s', time());
-		
-		$xmlstr = '<data version="9">'
-			. '<searches>'
-			. '<search libraryID="' . $libraryID . '" '
-				. 'name="' . $name . '" '
-				. 'dateAdded="' . $dateAdded . '" '
-				. 'dateModified="' . $dateModified . '" '
-				. 'key="' . $key . '">';
-		$i = 1;
-		foreach ($conditions as $condition) {
-			$xmlstr .= '<condition id="' . $i . '" '
-				. 'condition="' . $condition['condition'] . '" '
-				. 'operator="' . $condition['operator'] . '" '
-				. 'value="' . $condition['value'] . '"/>';
-			$i++;
-		}
-		$xmlstr .= '</search>'
-			. '</searches>'
-			. '</data>';
-		$response = Sync::upload($sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload($sessionID, $response, $context);
-		
-		return $key;
-	}
-	
-	
-	//
-	// Sync operations
-	//
-	public static function login($credentials=false) {
-		if (!$credentials) {
-			$credentials['username'] = self::$config['username'];
-			$credentials['password'] = self::$config['password'];
-		}
-		
-		$url = self::$config['syncURLPrefix'] . "login";
-		$response = HTTP::post(
-			$url,
-			array(
-				"version" => self::$config['syncVersion'],
-				"username" => $credentials['username'],
-				"password" => $credentials['password']
-			)
-		);
-		self::checkResponse($response);
-		$xml = new SimpleXMLElement($response->getBody());
-		$sessionID = (string) $xml->sessionID;
-		self::checkSessionID($sessionID);
-		return $sessionID;
-	}
-	
-	
-	public static function updated($sessionID, $lastsync=1, $allowError=false, $allowQueued=false, $params=array()) {
-		$response = self::req($sessionID, "updated", array_merge(array("lastsync" => $lastsync), $params));
-		$xml = Sync::getXMLFromResponse($response);
-		
-		if (isset($xml->updated) || (isset($xml->error) && $allowError)
-				|| (isset($xml->locked) && $allowQueued)) {
-			return $xml;
-		}
-		
-		if (!isset($xml->locked)) {
-			var_dump($xml->asXML());
-			throw new Exception("Not locked");
-		}
-		
-		$max = 5;
-		$try = 0;
-		do {
-			$wait = (int) $xml->locked['wait'];
-			sleep($wait / 1000);
-			
-			$xml = Sync::updated($sessionID, $lastsync, $allowError, true, $params);
-			
-			$try++;
-		}
-		while (isset($xml->locked) && $try < $max);
-		
-		if (isset($xml->locked)) {
-			throw new Exception("Download did not finish after $try attempts");
-		}
-		
-		if (!$allowError && !isset($xml->updated)) {
-			var_dump($xml->asXML());
-			throw new Exception("<updated> not found");
-		}
-		
-		return $xml;
-	}
-	
-	
-	public static function upload($sessionID, $updateKey, $data, $allowError=false) {
-		return self::req(
-			$sessionID,
-			"upload",
-			array(
-				"updateKey" => $updateKey,
-				"data" => $data,
-			),
-			true,
-			$allowError
-		);
-	}
-	
-	
-	public static function uploadstatus($sessionID, $allowError=false) {
-		return self::req($sessionID, "uploadstatus", false, false, true);
-	}
-	
-	
-	public static function waitForUpload($sessionID, $response, $context, $allowError=false) {
-		$xml = Sync::getXMLFromResponse($response);
-		
-		if (isset($xml->uploaded) || (isset($xml->error) && $allowError))  {
-			return $xml;
-		}
-		
-		$context->assertTrue(isset($xml->queued));
-		
-		$max = 5;
-		do {
-			$wait = (int) $xml->queued['wait'];
-			sleep($wait / 1000);
-			
-			$response = Sync::uploadStatus($sessionID, $allowError);
-			$xml = Sync::getXMLFromResponse($response);
-			
-			$max--;
-		}
-		while (isset($xml->queued) && $max > 0);
-		
-		if (!$max) {
-			$context->fail("Upload did not finish after $max attempts");
-		}
-		
-		if (!$allowError) {
-			$context->assertTrue(isset($xml->uploaded));
-		}
-		
-		return $xml;
-	}
-	
-	
-	public static function logout($sessionID) {
-		$url = self::$config['syncURLPrefix'] . "logout";
-		$response = HTTP::post(
-			$url,
-			array(
-				"version" => self::$config['syncVersion'],
-				"sessionid" => $sessionID
-			)
-		);
-		self::checkResponse($response);
-		$xml = new SimpleXMLElement($response->getBody());
-		if (!$xml->loggedout) {
-			throw new Exception("Error logging out");
-		}
-	}
-	
-	
-	public static function checkResponse($response, $allowError=false) {
-		$responseText = $response->getBody();
-		
-		if (empty($responseText)) {
-			throw new Exception("Response is empty");
-		}
-		
-		$domdoc = new DOMDocument;
-		try {
-			$domdoc->loadXML($responseText);
-		}
-		catch (Exception $e) {
-			var_dump($responseText);
-			throw ($e);
-		}
-		if ($domdoc->firstChild->tagName != "response") {
-			throw new Exception("Invalid XML output: " . $responseText);
-		}
-		
-		if (!$allowError && $domdoc->firstChild->firstChild->tagName == "error") {
-			if ($domdoc->firstChild->firstChild->getAttribute('code') == "INVALID_LOGIN") {
-				throw new Exception("Invalid login");
-			}
-			
-			throw new Exception($responseText);
-		}
-	}
-	
-	
-	public static function getXMLFromResponse($response) {
-		try {
-			$xml = new SimpleXMLElement($response->getBody());
-		}
-		catch (Exception $e) {
-			var_dump($response->getBody());
-			throw $e;
-		}
-		$xml->registerXPathNamespace('atom', 'http://www.w3.org/2005/Atom');
-		$xml->registerXPathNamespace('zapi', 'http://zotero.org/ns/api');
-		return $xml;
-	}
-
-	
-	
-	private static function req($sessionID, $path, $params=array(), $gzip=false, $allowError=false) {
-		$url = self::$config['syncURLPrefix'] . $path;
-		
-		$params = array_merge(
-			array(
-				"sessionid" => $sessionID,
-				"version" => self::$config['syncVersion']
-			),
-			$params ? $params : array()
-		);
-		
-		if ($gzip) {
-			$data = "";
-			foreach ($params as $key => $val) {
-				$data .= $key . "=" . urlencode($val) . "&";
-			}
-			$data = gzdeflate(substr($data, 0, -1));
-			$headers = [
-				"Content-Type: application/octet-stream",
-				"Content-Encoding: gzip"
-			];
-		}
-		else {
-			$data = $params;
-			$headers = [];
-			
-		}
-		
-		if (!empty(self::$config['zoteroVersion'])) {
-			$headers[] = "X-Zotero-Version: " . self::$config['zoteroVersion'];
-		}
-		
-		$response = HTTP::post($url, $data, $headers);
-		self::checkResponse($response, $allowError);
-		return $response;
-	}
-	
-	
-	private static function checkSessionID($sessionID) {
-		if (!preg_match('/^[a-g0-9]{32}$/', $sessionID)) {
-			throw new Exception("Invalid session id");
-		}
-	}
-}
-
-Sync::loadConfig();
diff --git a/tests/remote/tests/API/1/APITests.inc.php b/tests/remote/tests/API/1/APITests.inc.php
deleted file mode 100644
index f064a128..00000000
--- a/tests/remote/tests/API/1/APITests.inc.php
+++ /dev/null
@@ -1,162 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv1;
-require_once 'tests/API/APITests.inc.php';
-use \API2 as API, \Exception, \SimpleXMLElement;
-require_once 'include/api2.inc.php';
-
-//
-// Helper functions
-//
-class APITests extends \APITests {
-	protected static $config;
-	protected static $nsZAPI;
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		self::$nsZAPI = 'http://zotero.org/ns/api';
-		
-		API::useAPIVersion(1);
-		
-		// Enable note access
-		API::setKeyOption(
-			self::$config['userID'], self::$config['apiKey'], 'libraryNotes', 1
-		);
-	}
-	
-	
-	public function setUp() {
-		parent::setUp();
-		API::useAPIVersion(1);
-	}
-	
-	
-	public function test() {}
-	
-	public function __call($name, $arguments) {
-		if (preg_match("/^assert([1-5][0-9]{2})$/", $name, $matches)) {
-			$this->assertHTTPStatus($matches[1], $arguments[0]);
-			// Check response body
-			if (isset($arguments[1])) {
-				$this->assertEquals($arguments[1], $arguments[0]->getBody());
-			}
-			return;
-		}
-		// assertNNNForObject($response, $message=false, $pos=0)
-		if (preg_match("/^assert([1-5][0-9]{2}|Unchanged)ForObject$/", $name, $matches)) {
-			$code = $matches[1];
-			if ($arguments[0] instanceof \HTTP_Request2_Response) {
-				$this->assert200($arguments[0]);
-				$json = json_decode($arguments[0]->getBody(), true);
-			}
-			else if (is_string($arguments[0])) {
-				$json = json_decode($arguments[0], true);
-			}
-			else {
-				$json = $arguments[0];
-			}
-			$this->assertNotNull($json);
-			
-			$expectedMessage = !empty($arguments[1]) ? $arguments[1] : false;
-			$index = isset($arguments[2]) ? $arguments[2] : 0;
-			
-			if ($code == 200) {
-				$this->assertArrayHasKey('success', $json);
-				if (!isset($json['success'][$index])) {
-					var_dump($json);
-					throw new Exception("Index $index not found in success object");
-				}
-				if ($expectedMessage) {
-					throw new Exception("Cannot check response message of object for HTTP $code");
-				}
-			}
-			else if ($code == 'Unchanged') {
-				$this->assertArrayHasKey('unchanged', $json);
-				$this->assertArrayHasKey($index, $json['unchanged']);
-				if ($expectedMessage) {
-					throw new Exception("Cannot check response message of unchanged object");
-				}
-			}
-			else if ($code[0] == '4' || $code[0] == '5') {
-				$this->assertArrayHasKey('failed', $json);
-				$this->assertArrayHasKey($index, $json['failed']);
-				$this->assertEquals($code, $json['failed'][$index]['code']);
-				if ($expectedMessage) {
-					$this->assertEquals($expectedMessage, $json['failed'][$index]['message']);
-				}
-			}
-			else {
-				throw new Exception("HTTP $code cannot be returned for an individual object");
-			}
-			return;
-		}
-		throw new Exception("Invalid function $name");
-	}
-	
-	
-	protected function assertHasResults($res) {
-		$xml = $res->getBody();
-		$xml = new SimpleXMLElement($xml);
-		
-		$zapiNodes = $xml->children(self::$nsZAPI);
-		$this->assertNotEquals(0, (int) $zapiNodes->totalResults);
-		$this->assertNotEquals(0, count($xml->entry));
-	}
-	
-	
-	protected function assertNumResults($num, $res) {
-		$xml = $res->getBody();
-		$xml = new SimpleXMLElement($xml);
-		
-		$this->assertEquals($num, count($xml->entry));
-	}
-	
-	protected function assertTotalResults($num, $res) {
-		$xml = $res->getBody();
-		$xml = new SimpleXMLElement($xml);
-		
-		$zapiNodes = $xml->children(self::$nsZAPI);
-		$this->assertEquals($num, (int) $zapiNodes->totalResults);
-	}
-	
-	
-	protected function assertNoResults($res) {
-		$xml = $res->getBody();
-		$xml = new SimpleXMLElement($xml);
-		
-		$zapiNodes = $xml->children(self::$nsZAPI);
-		$this->assertEquals(1, count($zapiNodes->totalResults));
-		$this->assertEquals(0, (int) $zapiNodes->totalResults);
-		$this->assertEquals(0, count($xml->entry));
-	}
-}
-
diff --git a/tests/remote/tests/API/1/CollectionTest.php b/tests/remote/tests/API/1/CollectionTest.php
deleted file mode 100644
index 33629abc..00000000
--- a/tests/remote/tests/API/1/CollectionTest.php
+++ /dev/null
@@ -1,170 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv1;
-use \API2 AS API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class CollectionTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testNewSingleCollection() {
-		$name = "Test Collection";
-		
-		$json = array(
-			'name' => $name,
-			'parent' => false
-		);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-		$this->assertEquals(0, (int) array_get_first($xml->xpath('/atom:feed/zapi:numCollections')));
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		
-		$json = json_decode($data['content']);
-		$this->assertEquals($name, (string) $json->name);
-		
-		return $data;
-	}
-	
-	
-	/**
-	 * @depends testNewSingleCollection
-	 */
-	public function testNewSingleSubcollection($data) {
-		$name = "Test Subcollection";
-		$parent = $data['key'];
-		
-		$json = array(
-			'name' => $name,
-			'parent' => $parent
-		);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		
-		$json = json_decode($data['content']);
-		$this->assertEquals($name, (string) $json->name);
-		$this->assertEquals($parent, (string) $json->parent);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$parent?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('/atom:entry/zapi:numCollections')));
-	}
-	
-	
-	public function testNewSingleCollectionWithoutParentProperty() {
-		$name = "Test Collection";
-		
-		$json = array(
-			'name' => $name
-		);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals($name, (string) $json->name);
-	}
-	
-	
-	public function testEditSingleCollection() {
-		API::useAPIVersion(2);
-		$xml = API::createCollection("Test", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$key = $data['key'];
-		$version = $data['version'];
-		API::useAPIVersion(1);
-		
-		$xml = API::getCollectionXML($data['key']);
-		$etag = (string) array_get_first($xml->xpath('//atom:entry/atom:content/@etag'));
-		$this->assertNotNull($etag);
-		
-		$newName = "Test 2";
-		$json = array(
-			"name" => $newName,
-			"parent" => false
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"collections/$key?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Match: $etag"
-			)
-		);
-		$this->assert200($response);
-		
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals($newName, (string) $json->name);
-	}
-}
diff --git a/tests/remote/tests/API/1/ItemTest.php b/tests/remote/tests/API/1/ItemTest.php
deleted file mode 100644
index b441216c..00000000
--- a/tests/remote/tests/API/1/ItemTest.php
+++ /dev/null
@@ -1,66 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv1;
-use \API2 AS API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class ItemTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-	}
-	
-	
-	public function testCreateItemWithChildren() {
-		$json = API::getItemTemplate("newspaperArticle");
-		$noteJSON = API::getItemTemplate("note");
-		$noteJSON->note = "<p>Here's a test note</p>";
-		$json->notes = array(
-			$noteJSON
-		);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			))
-		);
-		$this->assert201($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertNumResults(1, $response);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('//atom:entry/zapi:numChildren')));
-	}
-}
diff --git a/tests/remote/tests/API/1/TranslationTest.php b/tests/remote/tests/API/1/TranslationTest.php
deleted file mode 100644
index f27f691e..00000000
--- a/tests/remote/tests/API/1/TranslationTest.php
+++ /dev/null
@@ -1,83 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2014 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 *****
-*/
-
-namespace APIv1;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class TranslationTests extends APITests {
-	public function setUp() {
-		parent::setUp();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	/**
-	 * @group translation
-	 */
-	public function testWebTranslationSingle() {
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"url" => "http://www.amazon.com/Zotero-Guide-Librarians-Researchers-Educators/dp/0838985890/"
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert201($response);
-		$xml = API::getXMLFromResponse($response);
-		$json = json_decode(API::parseDataFromAtomEntry($xml)['content']);
-		$this->assertEquals('Zotero: A Guide for Librarians, Researchers and Educators', $json->title);
-	}
-	
-	
-	public function testWebTranslationMultiple() {
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"url" => "http://www.amazon.com/s/field-keywords=zotero+guide+librarians"
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert300($response);
-		$json = json_decode($response->getBody());
-		$results = get_object_vars($json);
-		
-		$key = array_keys($results)[0];
-		$val = array_values($results)[0];
-		
-		$this->assertEquals(0, strpos($key, 'http'));
-		$this->assertEquals('Zotero: A guide for librarians, researchers, and educators, Second Edition', $val);
-		
-		// Can't test posting on v1, because generated token isn't returned
-	}
-}
diff --git a/tests/remote/tests/API/2/APITests.inc.php b/tests/remote/tests/API/2/APITests.inc.php
deleted file mode 100644
index 62016502..00000000
--- a/tests/remote/tests/API/2/APITests.inc.php
+++ /dev/null
@@ -1,180 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-require_once 'tests/API/APITests.inc.php';
-use API2 as API, Exception, SimpleXMLElement;
-require_once 'include/api2.inc.php';
-
-//
-// Helper functions
-//
-class APITests extends \APITests {
-	protected static $config;
-	protected static $nsZAPI;
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		self::$nsZAPI = 'http://zotero.org/ns/api';
-		
-		API::useAPIVersion(2);
-		
-		// Enable note access
-		API::setKeyOption(
-			self::$config['userID'], self::$config['apiKey'], 'libraryNotes', 1
-		);
-	}
-	
-	
-	public function setUp() {
-		parent::setUp();
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function test() {}
-	
-	public function __call($name, $arguments) {
-		if (preg_match("/^assert([1-5][0-9]{2})$/", $name, $matches)) {
-			$this->assertHTTPStatus($matches[1], $arguments[0]);
-			// Check response body
-			if (isset($arguments[1])) {
-				$this->assertEquals($arguments[1], $arguments[0]->getBody());
-			}
-			return;
-		}
-		// assertNNNForObject($response, $message=false, $pos=0)
-		if (preg_match("/^assert([1-5][0-9]{2}|Unchanged)ForObject$/", $name, $matches)) {
-			$code = $matches[1];
-			if ($arguments[0] instanceof \HTTP_Request2_Response) {
-				$this->assert200($arguments[0]);
-				$json = json_decode($arguments[0]->getBody(), true);
-			}
-			else if (is_string($arguments[0])) {
-				$json = json_decode($arguments[0], true);
-			}
-			else {
-				$json = $arguments[0];
-			}
-			$this->assertNotNull($json);
-			
-			$expectedMessage = !empty($arguments[1]) ? $arguments[1] : false;
-			$index = isset($arguments[2]) ? $arguments[2] : 0;
-			
-			if ($code == 200) {
-				try {
-					$this->assertArrayHasKey('success', $json);
-				}
-				catch (Exception $e) {
-					var_dump($json);
-					throw $e;
-				}
-				if (!isset($json['success'][$index])) {
-					var_dump($json);
-					throw new Exception("Index $index not found in success object");
-				}
-				if ($expectedMessage) {
-					throw new Exception("Cannot check response message of object for HTTP $code");
-				}
-			}
-			else if ($code == 'Unchanged') {
-				try {
-					$this->assertArrayHasKey('unchanged', $json);
-					$this->assertArrayHasKey($index, $json['unchanged']);
-				}
-				catch (Exception $e) {
-					var_dump($json);
-					throw $e;
-				}
-				if ($expectedMessage) {
-					throw new Exception("Cannot check response message of unchanged object");
-				}
-			}
-			else if ($code[0] == '4' || $code[0] == '5') {
-				try {
-					$this->assertArrayHasKey('failed', $json);
-					$this->assertArrayHasKey($index, $json['failed']);
-				}
-				catch (Exception $e) {
-					var_dump($json);
-					throw $e;
-				}
-				$this->assertEquals($code, $json['failed'][$index]['code']);
-				if ($expectedMessage) {
-					$this->assertEquals($expectedMessage, $json['failed'][$index]['message']);
-				}
-			}
-			else {
-				throw new Exception("HTTP $code cannot be returned for an individual object");
-			}
-			return;
-		}
-		throw new Exception("Invalid function $name");
-	}
-	
-	
-	protected function assertHasResults($res) {
-		$xml = $res->getBody();
-		$xml = new SimpleXMLElement($xml);
-		
-		$zapiNodes = $xml->children(self::$nsZAPI);
-		$this->assertNotEquals(0, (int) $zapiNodes->totalResults);
-		$this->assertNotEquals(0, count($xml->entry));
-	}
-	
-	
-	protected function assertNumResults($num, $res) {
-		$xml = $res->getBody();
-		$xml = new SimpleXMLElement($xml);
-		
-		$this->assertEquals($num, count($xml->entry));
-	}
-	
-	protected function assertTotalResults($num, $res) {
-		$xml = $res->getBody();
-		$xml = new SimpleXMLElement($xml);
-		
-		$zapiNodes = $xml->children(self::$nsZAPI);
-		$this->assertEquals($num, (int) $zapiNodes->totalResults);
-	}
-	
-	
-	protected function assertNoResults($res) {
-		$xml = $res->getBody();
-		$xml = new SimpleXMLElement($xml);
-		
-		$zapiNodes = $xml->children(self::$nsZAPI);
-		$this->assertEquals(1, count($zapiNodes->totalResults));
-		$this->assertEquals(0, (int) $zapiNodes->totalResults);
-		$this->assertEquals(0, count($xml->entry));
-	}
-}
-
diff --git a/tests/remote/tests/API/2/AtomTest.php b/tests/remote/tests/API/2/AtomTest.php
deleted file mode 100644
index cb92a8b6..00000000
--- a/tests/remote/tests/API/2/AtomTest.php
+++ /dev/null
@@ -1,140 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class AtomTests extends APITests {
-	private static $items;
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-		
-		// Create test data
-		$key = API::createItem("book", array(
-			"title" => "Title",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				)
-			)
-		), null, 'key');
-		self::$items[$key] = '<content xmlns:zapi="http://zotero.org/ns/api" type="application/xml"><zapi:subcontent zapi:type="bib"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 1.35; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, First. <i>Title</i>, n.d.</div></div></zapi:subcontent><zapi:subcontent zapi:type="json">'
-			. \Zotero_Utilities::formatJSON(json_decode('{"itemKey":"","itemVersion":0,"itemType":"book","title":"Title","creators":[{"creatorType":"author","firstName":"First","lastName":"Last"}],"abstractNote":"","series":"","seriesNumber":"","volume":"","numberOfVolumes":"","edition":"","place":"","publisher":"","date":"","numPages":"","language":"","ISBN":"","shortTitle":"","url":"","accessDate":"","archive":"","archiveLocation":"","libraryCatalog":"","callNumber":"","rights":"","extra":"","tags":[],"collections":[],"relations":{}}'))
-			. '</zapi:subcontent></content>';
-		
-		$key = API::createItem("book", array(
-			"title" => "Title 2",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				),
-				array(
-					"creatorType" => "editor",
-					"firstName" => "Ed",
-					"lastName" => "McEditor"
-				)
-			)
-		), null, 'key');
-		self::$items[$key] = '<content xmlns:zapi="http://zotero.org/ns/api" type="application/xml"><zapi:subcontent zapi:type="bib"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 1.35; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, First. <i>Title 2</i>. Edited by Ed McEditor, n.d.</div></div></zapi:subcontent><zapi:subcontent zapi:type="json">'
-			. \Zotero_Utilities::formatJSON(json_decode('{"itemKey":"","itemVersion":0,"itemType":"book","title":"Title 2","creators":[{"creatorType":"author","firstName":"First","lastName":"Last"},{"creatorType":"editor","firstName":"Ed","lastName":"McEditor"}],"abstractNote":"","series":"","seriesNumber":"","volume":"","numberOfVolumes":"","edition":"","place":"","publisher":"","date":"","numPages":"","language":"","ISBN":"","shortTitle":"","url":"","accessDate":"","archive":"","archiveLocation":"","libraryCatalog":"","callNumber":"","rights":"","extra":"","tags":[],"collections":[],"relations":{}}'))
-			. '</zapi:subcontent></content>';
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testFeedURIs() {
-		$userID = self::$config['userID'];
-		
-		$response = API::userGet(
-			$userID,
-			"items?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$links = $xml->xpath('/atom:feed/atom:link');
-		$this->assertEquals(self::$config['apiURLPrefix'] . "users/$userID/items", (string) $links[0]['href']);
-		
-		// 'order'/'sort' should stay as-is, not turn into 'sort'/'direction'
-		$response = API::userGet(
-			$userID,
-			"items?key=" . self::$config['apiKey'] . '&order=dateModified&sort=asc'
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$links = $xml->xpath('/atom:feed/atom:link');
-		$this->assertEquals(self::$config['apiURLPrefix'] . "users/$userID/items?order=dateModified&sort=asc", (string) $links[0]['href']);
-	}
-	
-	
-	public function testMultiContent() {
-		$keys = array_keys(self::$items);
-		$keyStr = implode(',', $keys);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey']
-				. "&itemKey=$keyStr&content=bib,json"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(sizeOf($keys), (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-		
-		$entries = $xml->xpath('//atom:entry');
-		foreach ($entries as $entry) {
-			$key = (string) $entry->children("http://zotero.org/ns/api")->key;
-			$content = $entry->content->asXML();
-			
-			// Add namespace prefix (from <entry>)
-			$content = str_replace('<content ', '<content xmlns:zapi="http://zotero.org/ns/api" ', $content);
-			// Strip variable key and version
-			$content = preg_replace(
-				'%"itemKey": "[A-Z0-9]{8}",(\s+)"itemVersion": [0-9]+%',
-				'"itemKey": "",$1"itemVersion": 0',
-				$content
-			);
-			
-			$this->assertXmlStringEqualsXmlString(self::$items[$key], $content);
-		}
-	}
-	
-	
-	public function testMultiContentCached() {
-		self::testMultiContent();
-	}
-}
-?>
diff --git a/tests/remote/tests/API/2/BibTest.php b/tests/remote/tests/API/2/BibTest.php
deleted file mode 100644
index ac88046e..00000000
--- a/tests/remote/tests/API/2/BibTest.php
+++ /dev/null
@@ -1,186 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class BibTests extends APITests {
-	private static $items;
-	private static $styles = array("default", "apa");
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-		
-		// Create test data
-		$key = API::createItem("book", array(
-			"title" => "Title",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				)
-			)
-		), null, 'key');
-		self::$items[$key] = array(
-			"citation" => array(
-				"default" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">Last, <i>Title</i>.</span></content>',
-				"apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">(Last, n.d.)</span></content>'
-			),
-			"bib" => array(
-				"default" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 1.35; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, First. <i>Title</i>, n.d.</div></div></content>',
-				"apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (n.d.). <i>Title</i>.</div></div></content>'
-			)
-		);
-		
-		$key = API::createItem("book", array(
-			"title" => "Title 2",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				),
-				array(
-					"creatorType" => "editor",
-					"firstName" => "Ed",
-					"lastName" => "McEditor"
-				)
-			)
-		), null, 'key');
-		self::$items[$key] = array(
-			"citation" => array(
-				"default" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">Last, <i>Title 2</i>.</span></content>',
-				"apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">(Last, n.d.)</span></content>'
-			),
-			"bib" => array(
-				"default" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 1.35; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, First. <i>Title 2</i>. Edited by Ed McEditor, n.d.</div></div></content>',
-				"apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (n.d.). <i>Title 2</i>. (E. McEditor, Ed.).</div></div></content>'
-			)
-		);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testContentCitationSingle() {
-		foreach (self::$styles as $style) {
-			foreach (self::$items as $key => $expected) {
-				$response = API::userGet(
-					self::$config['userID'],
-					"items/$key?key=" . self::$config['apiKey']
-						. "&content=citation"
-						. ($style == "default" ? "" : "&style=$style")
-				);
-				$this->assert200($response);
-				$content = API::getContentFromResponse($response);
-				// Add zapi namespace
-				$content = str_replace('<content ', '<content xmlns:zapi="http://zotero.org/ns/api" ', $content);
-				$this->assertXmlStringEqualsXmlString($expected['citation'][$style], $content);
-			}
-		}
-	}
-	
-	
-	public function testContentCitationMulti() {
-		$keys = array_keys(self::$items);
-		$keyStr = implode(',', $keys);
-		
-		foreach (self::$styles as $style) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items?key=" . self::$config['apiKey']
-					. "&itemKey=$keyStr&content=citation"
-					. ($style == "default" ? "" : "&style=$style")
-			);
-			$this->assert200($response);
-			$xml = API::getXMLFromResponse($response);
-			$this->assertEquals(sizeOf($keys), (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-			
-			$entries = $xml->xpath('//atom:entry');
-			foreach ($entries as $entry) {
-				$key = (string) $entry->children("http://zotero.org/ns/api")->key;
-				$content = $entry->content->asXML();
-				
-				// Add zapi namespace
-				$content = str_replace('<content ', '<content xmlns:zapi="http://zotero.org/ns/api" ', $content);
-				$this->assertXmlStringEqualsXmlString(self::$items[$key]['citation'][$style], $content);
-			}
-		}
-	}
-	
-	
-	public function testContentBibSingle() {
-		foreach (self::$styles as $style) {
-			foreach (self::$items as $key => $expected) {
-				$response = API::userGet(
-					self::$config['userID'],
-					"items/$key?key=" . self::$config['apiKey'] . "&content=bib"
-						. ($style == "default" ? "" : "&style=$style")
-				);
-				$this->assert200($response);
-				$content = API::getContentFromResponse($response);
-				// Add zapi namespace
-				$content = str_replace('<content ', '<content xmlns:zapi="http://zotero.org/ns/api" ', $content);
-				$this->assertXmlStringEqualsXmlString($expected['bib'][$style], $content);
-			}
-		}
-	}
-	
-	
-	public function testContentBibMulti() {
-		$keys = array_keys(self::$items);
-		$keyStr = implode(',', $keys);
-		
-		foreach (self::$styles as $style) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items?key=" . self::$config['apiKey']
-					. "&itemKey=$keyStr&content=bib"
-					. ($style == "default" ? "" : "&style=$style")
-			);
-			$this->assert200($response);
-			$xml = API::getXMLFromResponse($response);
-			$this->assertEquals(sizeOf($keys), (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-			
-			$entries = $xml->xpath('//atom:entry');
-			foreach ($entries as $entry) {
-				$key = (string) $entry->children("http://zotero.org/ns/api")->key;
-				$content = $entry->content->asXML();
-				
-				// Add zapi namespace
-				$content = str_replace('<content ', '<content xmlns:zapi="http://zotero.org/ns/api" ', $content);
-				$this->assertXmlStringEqualsXmlString(self::$items[$key]['bib'][$style], $content);
-			}
-		}
-	}
-}
diff --git a/tests/remote/tests/API/2/CacheTest.php b/tests/remote/tests/API/2/CacheTest.php
deleted file mode 100644
index d9dab0d7..00000000
--- a/tests/remote/tests/API/2/CacheTest.php
+++ /dev/null
@@ -1,77 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class CacheTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	/**
-	 * An object type's primary data cache for a library has to be created before
-	 * 
-	 */
-	public function testCacheCreatorPrimaryData() {
-		$data = array(
-			"title" => "Title",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				),
-				array(
-					"creatorType" => "editor",
-					"firstName" => "Ed",
-					"lastName" => "McEditor"
-				)
-			)
-		);
-		
-		$key = API::createItem("book", $data, $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=csljson"
-		);
-		$json = json_decode(API::getContentFromResponse($response));
-		$this->assertEquals("First", $json->author[0]->given);
-		$this->assertEquals("Last", $json->author[0]->family);
-		$this->assertEquals("Ed", $json->editor[0]->given);
-		$this->assertEquals("McEditor", $json->editor[0]->family);
-	}
-}
diff --git a/tests/remote/tests/API/2/CollectionTest.php b/tests/remote/tests/API/2/CollectionTest.php
deleted file mode 100644
index 6440e50a..00000000
--- a/tests/remote/tests/API/2/CollectionTest.php
+++ /dev/null
@@ -1,346 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API, stdClass;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class CollectionTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testNewCollection() {
-		$name = "Test Collection";
-		
-		$xml = API::createCollection($name, false, $this, 'atom');
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		
-		$json = json_decode($data['content']);
-		$this->assertEquals($name, (string) $json->name);
-		
-		return $data;
-	}
-	
-	
-	/**
-	 * @depends testNewCollection
-	 */
-	public function testNewSubcollection($data) {
-		$name = "Test Subcollection";
-		$parent = $data['key'];
-		
-		$xml = API::createCollection($name, $parent, $this, 'atom');
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals($name, (string) $json->name);
-		$this->assertEquals($parent, (string) $json->parentCollection);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$parent?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('/atom:entry/zapi:numCollections')));
-	}
-	
-	
-	public function testNewMultipleCollections() {
-		$xml = API::createCollection("Test Collection 1", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		
-		$name1 = "Test Collection 2";
-		$name2 = "Test Subcollection";
-		$parent2 = $data['key'];
-		
-		$json = array(
-			"collections" => array(
-				array(
-					'name' => $name1
-				),
-				array(
-					'name' => $name2,
-					'parentCollection' => $parent2
-				)
-			)
-		);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json['success']);
-		$xml = API::getCollectionXML($json['success']);
-		$this->assertEquals(2, (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-		
-		$contents = $xml->xpath('/atom:feed/atom:entry/atom:content');
-		$content = json_decode(array_shift($contents));
-		$this->assertEquals($name1, $content->name);
-		$this->assertFalse($content->parentCollection);
-		$content = json_decode(array_shift($contents));
-		$this->assertEquals($name2, $content->name);
-		$this->assertEquals($parent2, $content->parentCollection);
-	}
-	
-	
-	public function testEditMultipleCollections() {
-		$xml = API::createCollection("Test 1", false, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$key1 = $data['key'];
-		$xml = API::createCollection("Test 2", false, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$key2 = $data['key'];
-		
-		$newName1 = "Test 1 Modified";
-		$newName2 = "Test 2 Modified";
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"collections" => array(
-					array(
-						'collectionKey' => $key1,
-						'name' => $newName1
-					),
-					array(
-						'collectionKey' => $key2,
-						'name' => $newName2
-					)
-				)
-			)),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: " . $data['version']
-			)
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json['success']);
-		$xml = API::getCollectionXML($json['success']);
-		$this->assertEquals(2, (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-		
-		$contents = $xml->xpath('/atom:feed/atom:entry/atom:content');
-		$content = json_decode(array_shift($contents));
-		$this->assertEquals($newName1, $content->name);
-		$this->assertFalse($content->parentCollection);
-		$content = json_decode(array_shift($contents));
-		$this->assertEquals($newName2, $content->name);
-		$this->assertFalse($content->parentCollection);
-	}
-	
-	
-	public function testCollectionItemChange() {
-		$collectionKey1 = API::createCollection('Test', false, $this, 'key');
-		$collectionKey2 = API::createCollection('Test', false, $this, 'key');
-		
-		$xml = API::createItem("book", array(
-			'collections' => array($collectionKey1)
-		), $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$itemKey1 = $data['key'];
-		$itemVersion1 = $data['version'];
-		$json = json_decode($data['content']);
-		$this->assertEquals(array($collectionKey1), $json->collections);
-		
-		$xml = API::createItem("journalArticle", array(
-			'collections' => array($collectionKey2)
-		), $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$itemKey2 = $data['key'];
-		$itemVersion2 = $data['version'];
-		$json = json_decode($data['content']);
-		$this->assertEquals(array($collectionKey2), $json->collections);
-		
-		$xml = API::getCollectionXML($collectionKey1);
-		$collectionData1 = API::parseDataFromAtomEntry($xml);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('//atom:entry/zapi:numItems')));
-		
-		$xml = API::getCollectionXML($collectionKey2);
-		$collectionData2 = API::parseDataFromAtomEntry($xml);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('//atom:entry/zapi:numItems')));
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Add items to collection
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$itemKey1?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"collections" => array($collectionKey1, $collectionKey2)
-			)),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $itemVersion1"
-			)
-		);
-		$this->assert204($response);
-		
-		// Item version should change
-		$xml = API::getItemXML($itemKey1);
-		$data = API::parseDataFromAtomEntry($xml);
-		$this->assertEquals($libraryVersion + 1, $data['version']);
-		
-		// Collection timestamp shouldn't change, but numItems should
-		$xml = API::getCollectionXML($collectionKey2);
-		$data = API::parseDataFromAtomEntry($xml);
-		$this->assertEquals(2, (int) array_get_first($xml->xpath('//atom:entry/zapi:numItems')));
-		$this->assertEquals($collectionData2['version'], $data['version']);
-		$collectionData2 = $data;
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Remove collections
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$itemKey2?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"collections" => array()
-			)),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $itemVersion2"
-			)
-		);
-		$this->assert204($response);
-		
-		// Item version should change
-		$xml = API::getItemXML($itemKey2);
-		$data = API::parseDataFromAtomEntry($xml);
-		$this->assertEquals($libraryVersion + 1, $data['version']);
-		
-		// Collection timestamp shouldn't change, but numItems should
-		$xml = API::getCollectionXML($collectionKey2);
-		$data = API::parseDataFromAtomEntry($xml);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('//atom:entry/zapi:numItems')));
-		$this->assertEquals($collectionData2['version'], $data['version']);
-		
-		// Check collections arrays and numItems
-		$xml = API::getItemXML($itemKey1);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertCount(2, $json->collections);
-		$this->assertContains($collectionKey1, $json->collections);
-		$this->assertContains($collectionKey2, $json->collections);
-		
-		$xml = API::getItemXML($itemKey2);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertCount(0, $json->collections);
-		
-		$xml = API::getCollectionXML($collectionKey1);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('//atom:entry/zapi:numItems')));
-		
-		$xml = API::getCollectionXML($collectionKey2);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('//atom:entry/zapi:numItems')));
-	}
-	
-	
-	public function testCollectionChildItemError() {
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		
-		$key = API::createItem("book", array(), $this, 'key');
-		$xml = API::createNoteItem("<p>Test Note</p>", $key, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$json['collections'] = array($collectionKey);
-		$json['relations'] = new stdClass;
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'],
-			json_encode($json),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert400($response);
-		$this->assertEquals("Child items cannot be assigned to collections", $response->getBody());
-	}
-	
-	
-	public function testCollectionItems() {
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		
-		$xml = API::createItem("book", array('collections' => array($collectionKey)), $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$itemKey1 = $data['key'];
-		$itemVersion1 = $data['version'];
-		$json = json_decode($data['content']);
-		$this->assertEquals(array($collectionKey), $json->collections);
-		
-		$xml = API::createItem("journalArticle", array('collections' => array($collectionKey)), $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$itemKey2 = $data['key'];
-		$itemVersion2 = $data['version'];
-		$json = json_decode($data['content']);
-		$this->assertEquals(array($collectionKey), $json->collections);
-		
-		$childItemKey1 = API::createAttachmentItem("linked_url", [], $itemKey1, $this, 'key');
-		$childItemKey2 = API::createAttachmentItem("linked_url", [], $itemKey2, $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items?key=" . self::$config['apiKey'] . "&format=keys"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(4, $keys);
-		$this->assertContains($itemKey1, $keys);
-		$this->assertContains($itemKey2, $keys);
-		$this->assertContains($childItemKey1, $keys);
-		$this->assertContains($childItemKey2, $keys);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?key=" . self::$config['apiKey'] . "&format=keys"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($itemKey1, $keys);
-		$this->assertContains($itemKey2, $keys);
-	}
-}
-?>
diff --git a/tests/remote/tests/API/2/CreatorTest.php b/tests/remote/tests/API/2/CreatorTest.php
deleted file mode 100644
index df467d6c..00000000
--- a/tests/remote/tests/API/2/CreatorTest.php
+++ /dev/null
@@ -1,97 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class CreatorTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testCreatorSummary() {
-		$xml = API::createItem("book", array(
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"name" => "Test"
-				)
-			)
-		), $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$itemKey = $data['key'];
-		$json = json_decode($data['content'], true);
-		
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("Test", $creatorSummary);
-		
-		$json['creators'][] = array(
-			"creatorType" => "author",
-			"firstName" => "Alice",
-			"lastName" => "Foo"
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$itemKey?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		$xml = API::getItemXML($itemKey);
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("Test and Foo", $creatorSummary);
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		
-		$json['creators'][] = array(
-			"creatorType" => "author",
-			"firstName" => "Bob",
-			"lastName" => "Bar"
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$itemKey?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		$xml = API::getItemXML($itemKey);
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("Test et al.", $creatorSummary);
-	}
-}
diff --git a/tests/remote/tests/API/2/FileTest.php b/tests/remote/tests/API/2/FileTest.php
deleted file mode 100644
index 2f5eadf7..00000000
--- a/tests/remote/tests/API/2/FileTest.php
+++ /dev/null
@@ -1,1301 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API, \HTTP, \SimpleXMLElement, \Sync, \Z_Tests, \ZipArchive;
-require_once 'APITests.inc.php';
-require_once 'include/bootstrap.inc.php';
-
-/**
- * @group s3
- */
-class FileTests extends APITests {
-	private static $toDelete = array();
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public function setUp() {
-		parent::setUp();
-		
-		// Delete work files
-		$delete = array("file", "old", "new", "patch");
-		foreach ($delete as $file) {
-			if (file_exists("work/$file")) {
-				unlink("work/$file");
-			}
-		}
-		clearstatcache();
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		
-		$s3Client = Z_Tests::$AWS->createS3();
-		
-		foreach (self::$toDelete as $file) {
-			try {
-				$s3Client->deleteObject([
-					'Bucket' => self::$config['s3Bucket'],
-					'Key' => $file
-				]);
-			}
-			catch (\Aws\S3\Exception\S3Exception $e) {
-				if ($e->getAwsErrorCode() == 'NoSuchKey') {
-					echo "\n$file not found on S3 to delete\n";
-				}
-				else {
-					throw $e;
-				}
-			}
-		}
-	}
-	
-	
-	public function testNewEmptyImportedFileAttachmentItem() {
-		$xml = API::createAttachmentItem("imported_file", [], false, $this);
-		return API::parseDataFromAtomEntry($xml);
-	}
-	
-	
-	/**
-	 * @depends testNewEmptyImportedFileAttachmentItem
-	 */
-	public function testAddFileAuthorizationErrors($data) {
-		$fileContents = self::getRandomUnicodeString();
-		$hash = md5($fileContents);
-		$mtime = time() * 1000;
-		$size = strlen($fileContents);
-		$filename = "test_" . $fileContents;
-		
-		$fileParams = array(
-			"md5" => $hash,
-			"filename" => $filename,
-			"filesize" => $size,
-			"mtime" => $mtime,
-			"contentType" => "text/plain",
-			"charset" => "utf-8"
-		);
-		
-		// Check required params
-		foreach (array("md5", "filename", "filesize", "mtime") as $exclude) {
-			$response = API::userPost(
-				self::$config['userID'],
-				"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-				$this->implodeParams($fileParams, array($exclude)),
-				array(
-					"Content-Type: application/x-www-form-urlencoded",
-					"If-None-Match: *"
-				)
-			);
-			$this->assert400($response);
-		}
-		
-		// Seconds-based mtime
-		$fileParams2 = $fileParams;
-		$fileParams2['mtime'] = round($mtime / 1000);
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			$this->implodeParams($fileParams2),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		// TODO: Enable this test when the dataserver enforces it
-		//$this->assert400($response);
-		//$this->assertEquals('mtime must be specified in milliseconds', $response->getBody());
-		
-		$fileParams = $this->implodeParams($fileParams);
-		
-		// Invalid If-Match
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			$fileParams,
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: " . md5("invalidETag")
-			)
-		);
-		$this->assert412($response);
-		
-		// Missing If-None-Match
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			$fileParams,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			)
-		);
-		$this->assert428($response);
-		
-		// Invalid If-None-Match
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			$fileParams,
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: invalidETag"
-			)
-		);
-		$this->assert400($response);
-	}
-	
-	
-	public function testAddFileFull() {
-		$xml = API::createItem("book", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$parentKey = $data['key'];
-		
-		$xml = API::createAttachmentItem("imported_file", [], $parentKey, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$originalVersion = $data['version'];
-		
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = filesize($file);
-		$contentType = "text/plain";
-		$charset = "utf-8";
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType,
-				"charset" => $charset
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody());
-		$this->assertNotNull($json);
-		
-		self::$toDelete[] = "$hash";
-		
-		// Upload to S3
-		$response = HTTP::post(
-			$json->url,
-			$json->prefix . $fileContents . $json->suffix,
-			array(
-				"Content-Type: " . $json->contentType
-			)
-		);
-		$this->assert201($response);
-		
-		//
-		// Register upload
-		//
-		
-		// No If-None-Match
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			"upload=" . $json->uploadKey,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			)
-		);
-		$this->assert428($response);
-		
-		// Invalid upload key
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			"upload=invalidUploadKey",
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert400($response);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			"upload=" . $json->uploadKey,
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert204($response);
-		
-		// Verify attachment item metadata
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$this->assertEquals($hash, $json->md5);
-		$this->assertEquals($filename, $json->filename);
-		$this->assertEquals($mtime, $json->mtime);
-		$this->assertEquals($contentType, $json->contentType);
-		$this->assertEquals($charset, $json->charset);
-		
-		return array(
-			"key" => $data['key'],
-			"json" => $json,
-			"size" => $size
-		);
-	}
-	
-	/**
-	 * @group classic-sync
-	 */
-	public function testAddFileFullParams() {
-		$xml = API::createAttachmentItem("imported_file", [], false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		
-		// Get serverDateModified
-		$serverDateModified = array_get_first($xml->xpath('/atom:entry/atom:updated'));
-		sleep(1);
-		
-		$originalVersion = $data['version'];
-		
-		// Get a sync timestamp from before the file is updated
-		require_once 'include/sync.inc.php';
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID);
-		$lastsync = (int) $xml['timestamp'];
-		Sync::logout($sessionID);
-		
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = filesize($file);
-		$contentType = "text/plain";
-		$charset = "utf-8";
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType,
-				"charset" => $charset,
-				"params" => 1
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody());
-		$this->assertNotNull($json);
-		
-		self::$toDelete[] = "$hash";
-		
-		// Generate form-data -- taken from S3::getUploadPostData()
-		$boundary = "---------------------------" . md5(uniqid());
-		$prefix = "";
-		foreach ($json->params as $key => $val) {
-			$prefix .= "--$boundary\r\n"
-				. "Content-Disposition: form-data; name=\"$key\"\r\n\r\n"
-				. $val . "\r\n";
-		}
-		$prefix .= "--$boundary\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\n";
-		$suffix = "\r\n--$boundary--";
-		
-		// Upload to S3
-		$response = HTTP::post(
-			$json->url,
-			$prefix . $fileContents . $suffix,
-			array(
-				"Content-Type: multipart/form-data; boundary=$boundary"
-			)
-		);
-		$this->assert201($response);
-		
-		//
-		// Register upload
-		//
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			"upload=" . $json->uploadKey,
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert204($response);
-		
-		// Verify attachment item metadata
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$this->assertEquals($hash, $json->md5);
-		$this->assertEquals($filename, $json->filename);
-		$this->assertEquals($mtime, $json->mtime);
-		$this->assertEquals($contentType, $json->contentType);
-		$this->assertEquals($charset, $json->charset);
-		
-		// Make sure serverDateModified has changed
-		$this->assertNotEquals($serverDateModified, array_get_first($xml->xpath('/atom:entry/atom:updated')));
-		
-		// Make sure version has changed
-		$this->assertNotEquals($originalVersion, $data['version']);
-		
-		// Make sure new attachment is passed via sync
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, $lastsync);
-		Sync::logout($sessionID);
-		$this->assertGreaterThan(0, $xml->updated[0]->count());
-	}
-	
-	
-	/**
-	 * @depends testAddFileFull
-	 */
-	public function testAddFileExisting($addFileData) {
-		$key = $addFileData['key'];
-		$json = $addFileData['json'];
-		$md5 = $json->md5;
-		$size = $addFileData['size'];
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file?key=" . self::$config['apiKey'],
-			$this->implodeParams(array(
-				"md5" => $json->md5,
-				"filename" => $json->filename,
-				"filesize" => $size,
-				"mtime" => $json->mtime,
-				"contentType" => $json->contentType,
-				"charset" => $json->charset
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: " . $json->md5
-			)
-		);
-		$this->assert200($response);
-		$postJSON = json_decode($response->getBody());
-		$this->assertNotNull($postJSON);
-		$this->assertEquals(1, $postJSON->exists);
-		
-		// Get upload authorization for existing file with different filename
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file?key=" . self::$config['apiKey'],
-			$this->implodeParams(array(
-				"md5" => $json->md5,
-				"filename" => $json->filename . '等', // Unicode 1.1 character, to test signature generation
-				"filesize" => $size,
-				"mtime" => $json->mtime,
-				"contentType" => $json->contentType,
-				"charset" => $json->charset
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: " . $json->md5
-			)
-		);
-		$this->assert200($response);
-		$postJSON = json_decode($response->getBody());
-		$this->assertNotNull($postJSON);
-		$this->assertEquals(1, $postJSON->exists);
-		
-		return array(
-			"key" => $key,
-			"md5" => $md5,
-			"filename" => $json->filename . '等'
-		);
-	}
-	
-	
-	/**
-	 * @depends testAddFileExisting
-	 * @group attachments
-	 */
-	public function testGetFile($addFileData) {
-		$key = $addFileData['key'];
-		$md5 = $addFileData['md5'];
-		$filename = $addFileData['filename'];
-		
-		// Get in view mode
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key/file/view?key=" . self::$config['apiKey']
-		);
-		$this->assert302($response);
-		$location = $response->getHeader("Location");
-		$this->assertRegExp('/^https:\/\/[^\/]+\/[0-9]+\//', $location);
-		$filenameEncoded = rawurlencode($filename);
-		$this->assertEquals($filenameEncoded, substr($location, -1 * strlen($filenameEncoded)));
-		
-		// Get from view mode
-		$response = HTTP::get($location);
-		$this->assert200($response);
-		$this->assertEquals($md5, md5($response->getBody()));
-		
-		// Get in download mode
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key/file?key=" . self::$config['apiKey']
-		);
-		$this->assert302($response);
-		$location = $response->getHeader("Location");
-		
-		// Get from S3
-		$response = HTTP::get($location);
-		$this->assert200($response);
-		$this->assertEquals($md5, md5($response->getBody()));
-		
-		return array(
-			"key" => $key, 
-			"response" => $response
-		);
-	}
-	
-	
-	/**
-	 * @depends testGetFile
-	 * @group classic-sync
-	 */
-	public function testAddFilePartial($getFileData) {
-		// Get serverDateModified
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$getFileData['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$serverDateModified = (string) array_get_first($xml->xpath('/atom:entry/atom:updated'));
-		sleep(1);
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		$originalVersion = $data['version'];
-		
-		// Get a sync timestamp from before the file is updated
-		require_once 'include/sync.inc.php';
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID);
-		$lastsync = (int) $xml['timestamp'];
-		Sync::logout($sessionID);
-		
-		$oldFilename = "work/old";
-		$fileContents = $getFileData['response']->getBody();
-		file_put_contents($oldFilename, $fileContents);
-		
-		$newFilename = "work/new";
-		$patchFilename = "work/patch";
-		
-		$algorithms = array(
-			"bsdiff" => "bsdiff "
-				. escapeshellarg($oldFilename) . " "
-				. escapeshellarg($newFilename) . " "
-				. escapeshellarg($patchFilename),
-			"xdelta" => "xdelta3 -f -e -9 -S djw -s "
-				. escapeshellarg($oldFilename) . " "
-				. escapeshellarg($newFilename) . " "
-				. escapeshellarg($patchFilename),
-			"vcdiff" => "vcdiff encode "
-				. "-dictionary " . escapeshellarg($oldFilename) . " "
-				. " -target " . escapeshellarg($newFilename) . " "
-				. " -delta " . escapeshellarg($patchFilename)
-		);
-		
-		foreach ($algorithms as $algo => $cmd) {
-			clearstatcache();
-			
-			// Create random contents
-			file_put_contents($newFilename, uniqid(self::getRandomUnicodeString(), true));
-			$newHash = md5_file($newFilename);
-			
-			// Get upload authorization
-			$fileParams = array(
-				"md5" => $newHash,
-				"filename" => "test_" . $fileContents,
-				"filesize" => filesize($newFilename),
-				"mtime" => filemtime($newFilename) * 1000,
-				"contentType" => "text/plain",
-				"charset" => "utf-8"
-			);
-			$response = API::userPost(
-				self::$config['userID'],
-				"items/{$getFileData['key']}/file?key=" . self::$config['apiKey'],
-				$this->implodeParams($fileParams),
-				array(
-					"Content-Type: application/x-www-form-urlencoded",
-					"If-Match: " . md5_file($oldFilename)
-				)
-			);
-			$this->assert200($response);
-			$json = json_decode($response->getBody());
-			$this->assertNotNull($json);
-			
-			exec($cmd, $output, $ret);
-			if ($ret != 0) {
-				echo "Warning: Error running $algo -- skipping file upload test\n";
-				continue;
-			}
-			
-			$patch = file_get_contents($patchFilename);
-			$this->assertNotEquals("", $patch);
-			
-			self::$toDelete[] = "$newHash";
-			
-			// Upload patch file
-			$response = API::userPatch(
-				self::$config['userID'],
-				"items/{$getFileData['key']}/file?key=" . self::$config['apiKey']
-					. "&algorithm=$algo&upload=" . $json->uploadKey,
-				$patch,
-				array(
-					"If-Match: " . md5_file($oldFilename)
-				)
-			);
-			$this->assert204($response);
-			
-			unlink($patchFilename);
-			rename($newFilename, $oldFilename);
-			
-			// Verify attachment item metadata
-			$response = API::userGet(
-				self::$config['userID'],
-				"items/{$getFileData['key']}?key=" . self::$config['apiKey'] . "&content=json"
-			);
-			$xml = API::getXMLFromResponse($response);
-			$data = API::parseDataFromAtomEntry($xml);
-			$json = json_decode($data['content']);
-			$this->assertEquals($fileParams['md5'], $json->md5);
-			$this->assertEquals($fileParams['mtime'], $json->mtime);
-			$this->assertEquals($fileParams['contentType'], $json->contentType);
-			$this->assertEquals($fileParams['charset'], $json->charset);
-			
-			// Make sure version has changed
-			$this->assertNotEquals($originalVersion, $data['version']);
-			
-			// Make sure new attachment is passed via sync
-			$sessionID = Sync::login();
-			$xml = Sync::updated($sessionID, $lastsync);
-			Sync::logout($sessionID);
-			$this->assertGreaterThan(0, $xml->updated[0]->count());
-			
-			// Verify file on S3
-			$response = API::userGet(
-				self::$config['userID'],
-				"items/{$getFileData['key']}/file?key=" . self::$config['apiKey']
-			);
-			$this->assert302($response);
-			$location = $response->getHeader("Location");
-			
-			$response = HTTP::get($location);
-			$this->assert200($response);
-			$this->assertEquals($fileParams['md5'], md5($response->getBody()));
-			$t = $fileParams['contentType'];
-			$this->assertEquals(
-				$t . (($t && $fileParams['charset']) ? "; charset={$fileParams['charset']}" : ""),
-				$response->getHeader("Content-Type")
-			);
-		}
-	}
-	
-	
-	public function testExistingFileWithOldStyleFilename() {
-		$fileContents = self::getRandomUnicodeString();
-		$hash = md5($fileContents);
-		$filename = 'test.txt';
-		$size = strlen($fileContents);
-		
-		$parentKey = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("imported_file", [], $parentKey, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$key = $data['key'];
-		$originalVersion = $data['version'];
-		$mtime = time() * 1000;
-		$contentType = 'text/plain';
-		$charset = 'utf-8';
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType,
-				"charset" => $charset
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody());
-		$this->assertNotNull($json);
-		
-		// Upload to old-style location
-		self::$toDelete[] = "$hash/$filename";
-		self::$toDelete[] = "$hash";
-		$s3Client = Z_Tests::$AWS->createS3();
-		$s3Client->putObject([
-			'Bucket' => self::$config['s3Bucket'],
-			'Key' => $hash . '/' . $filename,
-			'Body' => $fileContents
-		]);
-		
-		// Register upload
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file?key=" . self::$config['apiKey'],
-			"upload=" . $json->uploadKey,
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert204($response);
-		
-		// The file should be accessible on the item at the old-style location
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key/file?key=" . self::$config['apiKey']
-		);
-		$this->assert302($response);
-		$location = $response->getHeader("Location");
-		$this->assertEquals(1, preg_match('"^https://'
-			// bucket.s3.amazonaws.com or s3.amazonaws.com/bucket
-			. '(?:[^/]+|.+' . self::$config['s3Bucket'] . ')'
-			. '/([a-f0-9]{32})/' . $filename . '\?"', $location, $matches));
-		$this->assertEquals($hash, $matches[1]);
-		
-		// Get upload authorization for the same file and filename on another item, which should
-		// result in 'exists', even though we uploaded to the old-style location
-		$parentKey = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("imported_file", [], $parentKey, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$key = $data['key'];
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file?key=" . self::$config['apiKey'],
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType,
-				"charset" => $charset
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert200($response);
-		$postJSON = json_decode($response->getBody());
-		$this->assertNotNull($postJSON);
-		$this->assertEquals(1, $postJSON->exists);
-		
-		// Get in download mode
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key/file?key=" . self::$config['apiKey']
-		);
-		$this->assert302($response);
-		$location = $response->getHeader("Location");
-		$this->assertEquals(1, preg_match('"^https://'
-			// bucket.s3.amazonaws.com or s3.amazonaws.com/bucket
-			. '(?:[^/]+|.+' . self::$config['s3Bucket'] . ')'
-			. '/([a-f0-9]{32})/' . $filename . '\?"', $location, $matches));
-		$this->assertEquals($hash, $matches[1]);
-		
-		// Get from S3
-		$response = HTTP::get($location);
-		$this->assert200($response);
-		$this->assertEquals($fileContents, $response->getBody());
-		$this->assertEquals($contentType . '; charset=' . $charset, $response->getHeader('Content-Type'));
-		
-		// Get upload authorization for the same file and different filename on another item,
-		// which should result in 'exists' and a copy of the file to the hash-only location
-		$parentKey = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("imported_file", [], $parentKey, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$key = $data['key'];
-		// Also use a different content type
-		$contentType = 'application/x-custom';
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file?key=" . self::$config['apiKey'],
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => "test2.txt",
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert200($response);
-		$postJSON = json_decode($response->getBody());
-		$this->assertNotNull($postJSON);
-		$this->assertEquals(1, $postJSON->exists);
-		
-		// Get in download mode
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key/file?key=" . self::$config['apiKey']
-		);
-		$this->assert302($response);
-		$location = $response->getHeader("Location");
-		$this->assertEquals(1, preg_match('"^https://'
-			// bucket.s3.amazonaws.com or s3.amazonaws.com/bucket
-			. '(?:[^/]+|.+' . self::$config['s3Bucket'] . ')'
-			. '/([a-f0-9]{32})\?"', $location, $matches));
-		$this->assertEquals($hash, $matches[1]);
-		
-		// Get from S3
-		$response = HTTP::get($location);
-		$this->assert200($response);
-		$this->assertEquals($fileContents, $response->getBody());
-		$this->assertEquals($contentType, $response->getHeader('Content-Type'));
-	}
-	
-	/**
-	 * @group classic-sync
-	 */
-	public function testAddFileClient() {
-		API::userClear(self::$config['userID']);
-		
-		$fileContentType = "text/html";
-		$fileCharset = "utf-8";
-		
-		$auth = array(
-			'username' => self::$config['username'],
-			'password' => self::$config['password']
-		);
-		
-		// Get last storage sync
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync?auth=1",
-			array(),
-			$auth
-		);
-		$this->assert404($response);
-		
-		$xml = API::createAttachmentItem("imported_file", [], false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$originalVersion = $data['version'];
-		$json = json_decode($data['content']);
-		$json->contentType = $fileContentType;
-		$json->charset = $fileCharset;
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		$originalVersion = $response->getHeader("Last-Modified-Version");
-		
-		// Get a sync timestamp from before the file is updated
-		sleep(1);
-		require_once 'include/sync.inc.php';
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID);
-		$lastsync = (int) $xml['timestamp'];
-		Sync::logout($sessionID);
-		
-		// Get file info
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}/file?auth=1&iskey=1&version=1&info=1",
-			array(),
-			$auth
-		);
-		$this->assert404($response);
-		
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = filesize($file);
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?auth=1&iskey=1&version=1",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/xml", $response);
-		$xml = new SimpleXMLElement($response->getBody());
-		
-		self::$toDelete[] = "$hash";
-		
-		$boundary = "---------------------------" . rand();
-		$postData = "";
-		foreach ($xml->params->children() as $key => $val) {
-			$postData .= "--" . $boundary . "\r\nContent-Disposition: form-data; "
-				. "name=\"$key\"\r\n\r\n$val\r\n";
-		}
-		$postData .= "--" . $boundary . "\r\nContent-Disposition: form-data; "
-				. "name=\"file\"\r\n\r\n" . $fileContents . "\r\n";
-		$postData .= "--" . $boundary . "--";
-		
-		// Upload to S3
-		$response = HTTP::post(
-			(string) $xml->url,
-			$postData,
-			array(
-				"Content-Type: multipart/form-data; boundary=" . $boundary
-			)
-		);
-		$this->assert201($response);
-		
-		//
-		// Register upload
-		//
-		
-		// Invalid upload key
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?auth=1&iskey=1&version=1",
-			"update=invalidUploadKey&mtime=" . $mtime,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert400($response);
-		
-		// No mtime
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?auth=1&iskey=1&version=1",
-			"update=" . $xml->key,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert500($response);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?auth=1&iskey=1&version=1",
-			"update=" . $xml->key . "&mtime=" . $mtime,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert204($response);
-		
-		// Verify attachment item metadata
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$this->assertEquals($hash, $json->md5);
-		$this->assertEquals($filename, $json->filename);
-		$this->assertEquals($mtime, $json->mtime);
-		
-		// Make sure attachment item wasn't updated (or else the client
-		// will get a conflict when it tries to update the metadata)
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, $lastsync);
-		Sync::logout($sessionID);
-		$this->assertEquals(0, $xml->updated[0]->count());
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync?auth=1",
-			array(),
-			array(
-				'username' => self::$config['username'],
-				'password' => self::$config['password']
-			)
-		);
-		$this->assert200($response);
-		$mtime = $response->getBody();
-		$this->assertRegExp('/^[0-9]{10}$/', $mtime);
-		
-		// File exists
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?auth=1&iskey=1&version=1",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime + 1000
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/xml", $response);
-		$this->assertEquals("<exists/>", $response->getBody());
-		
-		// File exists with different filename
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?auth=1&iskey=1&version=1",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename . '等', // Unicode 1.1 character, to test signature generation
-				"filesize" => $size,
-				"mtime" => $mtime + 1000
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/xml", $response);
-		$this->assertEquals("<exists/>", $response->getBody());
-		
-		// Make sure attachment item still wasn't updated
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, $lastsync);
-		$this->assertEquals(0, $xml->updated[0]->count());
-		
-		// Get attachment
-		$xml = Sync::updated($sessionID, 2);
-		$this->assertEquals(1, $xml->updated[0]->items->count());
-		$itemXML = $xml->updated[0]->items[0]->item[0]->asXML();
-		$this->assertEquals($fileContentType, (string) $xml->updated[0]->items[0]->item[0]['mimeType']);
-		$this->assertEquals($fileCharset, (string) $xml->updated[0]->items[0]->item[0]['charset']);
-		$this->assertEquals($hash, (string) $xml->updated[0]->items[0]->item[0]['storageHash']);
-		$this->assertEquals($mtime + 1000, (string) $xml->updated[0]->items[0]->item[0]['storageModTime']);
-		
-		Sync::logout($sessionID);
-	}
-	
-	/**
-	 * @group classic-sync
-	 */
-	public function testAddFileClientZip() {
-		API::userClear(self::$config['userID']);
-		
-		$auth = array(
-			'username' => self::$config['username'],
-			'password' => self::$config['password']
-		);
-		
-		// Get last storage sync
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync?auth=1",
-			array(),
-			$auth
-		);
-		$this->assert404($response);
-		
-		$xml = API::createItem("book", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$key = $data['key'];
-		
-		$fileContentType = "text/html";
-		$fileCharset = "UTF-8";
-		$fileFilename = "file.html";
-		$fileModtime = time();
-		
-		$xml = API::createAttachmentItem("imported_url", [], $key, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$key = $data['key'];
-		$version = $data['version'];
-		$json = json_decode($data['content']);
-		$json->contentType = $fileContentType;
-		$json->charset = $fileCharset;
-		$json->filename = $fileFilename;
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json"
-			)
-		);
-		$this->assert204($response);
-		
-		// Get a sync timestamp from before the file is updated
-		sleep(1);
-		require_once 'include/sync.inc.php';
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID);
-		$lastsync = (int) $xml['timestamp'];
-		Sync::logout($sessionID);
-		
-		// Get file info
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}/file?auth=1&iskey=1&version=1&info=1",
-			array(),
-			$auth
-		);
-		$this->assert404($response);
-		
-		$zip = new ZipArchive();
-		$file = "work/$key.zip";
-		
-		if ($zip->open($file, ZIPARCHIVE::CREATE) !== TRUE) {
-			throw new Exception("Cannot open ZIP file");
-		}
-		
-		$zip->addFromString($fileFilename, self::getRandomUnicodeString());
-		$zip->addFromString("file.css", self::getRandomUnicodeString());
-		$zip->close();
-		
-		$hash = md5_file($file);
-		$filename = $key . ".zip";
-		$size = filesize($file);
-		$fileContents = file_get_contents($file);
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?auth=1&iskey=1&version=1",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $fileModtime,
-				"zip" => 1
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/xml", $response);
-		$xml = new SimpleXMLElement($response->getBody());
-		
-		self::$toDelete[] = "$hash";
-		
-		$boundary = "---------------------------" . rand();
-		$postData = "";
-		foreach ($xml->params->children() as $key => $val) {
-			$postData .= "--" . $boundary . "\r\nContent-Disposition: form-data; "
-				. "name=\"$key\"\r\n\r\n$val\r\n";
-		}
-		$postData .= "--" . $boundary . "\r\nContent-Disposition: form-data; "
-				. "name=\"file\"\r\n\r\n" . $fileContents . "\r\n";
-		$postData .= "--" . $boundary . "--";
-		
-		// Upload to S3
-		$response = HTTP::post(
-			(string) $xml->url,
-			$postData,
-			array(
-				"Content-Type: multipart/form-data; boundary=" . $boundary
-			)
-		);
-		$this->assert201($response);
-		
-		//
-		// Register upload
-		//
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?auth=1&iskey=1&version=1",
-			"update=" . $xml->key . "&mtime=" . $fileModtime,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert204($response);
-		
-		// Verify attachment item metadata
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$this->assertEquals($hash, $json->md5);
-		$this->assertEquals($fileFilename, $json->filename);
-		$this->assertEquals($fileModtime, $json->mtime);
-		
-		// Make sure attachment item wasn't updated (or else the client
-		// will get a conflict when it tries to update the metadata)
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, $lastsync);
-		Sync::logout($sessionID);
-		$this->assertEquals(0, $xml->updated[0]->count());
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync?auth=1",
-			array(),
-			array(
-				'username' => self::$config['username'],
-				'password' => self::$config['password']
-			)
-		);
-		$this->assert200($response);
-		$mtime = $response->getBody();
-		$this->assertRegExp('/^[0-9]{10}$/', $mtime);
-		
-		// File exists
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?auth=1&iskey=1&version=1",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $fileModtime + 1000,
-				"zip" => 1
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/xml", $response);
-		$this->assertEquals("<exists/>", $response->getBody());
-		
-		// Make sure attachment item still wasn't updated
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, $lastsync);
-		Sync::logout($sessionID);
-		$this->assertEquals(0, $xml->updated[0]->count());
-	}
-	
-	
-	public function testAddFileLinkedAttachment() {
-		$xml = API::createAttachmentItem("linked_file", [], false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = filesize($file);
-		$contentType = "text/plain";
-		$charset = "utf-8";
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$data['key']}/file?key=" . self::$config['apiKey'],
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType,
-				"charset" => $charset
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert400($response);
-	}
-	
-	
-	private function implodeParams($params, $exclude=array()) {
-		$parts = array();
-		foreach ($params as $key => $val) {
-			if (in_array($key, $exclude)) {
-				continue;
-			}
-			$parts[] = $key . "=" . urlencode($val);
-		}
-		return implode("&", $parts);
-	}
-	
-	
-	private function getRandomUnicodeString() {
-		return "Âéìøü 这是一个测试。 " . uniqid();
-	}
-}
diff --git a/tests/remote/tests/API/2/FullTextTest.php b/tests/remote/tests/API/2/FullTextTest.php
deleted file mode 100644
index e5faf9be..00000000
--- a/tests/remote/tests/API/2/FullTextTest.php
+++ /dev/null
@@ -1,325 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-/**
- * @group fulltext
- */
-class FullTextTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testSetItemContent() {
-		$key = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("imported_url", [], $key, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}/fulltext?key=" . self::$config['apiKey']
-		);
-		$this->assert404($response);
-		$this->assertNull($response->getHeader("Last-Modified-Version"));
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$content = "Here is some full-text content";
-		$pages = 50;
-		
-		// No Content-Type
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}/fulltext?key=" . self::$config['apiKey'],
-			$content
-		);
-		$this->assert400($response, "Content-Type must be application/json");
-		
-		// Store content
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}/fulltext?key=" . self::$config['apiKey'],
-			json_encode([
-				"content" => $content,
-				"indexedPages" => $pages,
-				"totalPages" => $pages,
-				"invalidParam" => "shouldBeIgnored"
-			]),
-			array("Content-Type: application/json")
-		);
-		
-		$this->assert204($response);
-		$contentVersion = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($libraryVersion, $contentVersion);
-		
-		// Retrieve it
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}/fulltext?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertEquals($content, $json['content']);
-		$this->assertArrayHasKey('indexedPages', $json);
-		$this->assertArrayHasKey('totalPages', $json);
-		$this->assertEquals($pages, $json['indexedPages']);
-		$this->assertEquals($pages, $json['totalPages']);
-		$this->assertArrayNotHasKey("indexedChars", $json);
-		$this->assertArrayNotHasKey("invalidParam", $json);
-		$this->assertEquals($contentVersion, $response->getHeader("Last-Modified-Version"));
-	}
-	
-	
-	public function testModifyAttachmentWithFulltext() {
-		$key = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("imported_url", [], $key, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$content = "Here is some full-text content";
-		$pages = 50;
-		
-		// Store content
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}/fulltext?key=" . self::$config['apiKey'],
-			json_encode([
-				"content" => $content,
-				"indexedPages" => $pages,
-				"totalPages" => $pages
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		
-		$json = json_decode($data['content'], true);
-		$json['title'] = "This is a new attachment title";
-		$json['contentType'] = 'text/plain';
-		
-		// Modify attachment item
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("If-Unmodified-Since-Version: " . $data['version'])
-		);
-		$this->assert204($response);
-	}
-	
-	
-	public function testNewerContent() {
-		API::userClear(self::$config['userID']);
-		
-		// Store content for one item
-		$key = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("imported_url", [], $key, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$key1 = $data['key'];
-		
-		$content = "Here is some full-text content";
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key1/fulltext?key=" . self::$config['apiKey'],
-			json_encode([
-				"content" => $content
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		$contentVersion1 = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan(0, $contentVersion1);
-		
-		// And another
-		$key = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("imported_url", [], $key, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$key2 = $data['key'];
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key2/fulltext?key=" . self::$config['apiKey'],
-			json_encode([
-				"content" => $content
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		$contentVersion2 = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan(0, $contentVersion2);
-		
-		// Get newer one
-		$response = API::userGet(
-			self::$config['userID'],
-			"fulltext?key=" . self::$config['apiKey'] . "&newer=$contentVersion1"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$this->assertEquals($contentVersion2, $response->getHeader("Last-Modified-Version"));
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(1, $json);
-		$this->assertArrayHasKey($key2, $json);
-		$this->assertEquals($contentVersion2, $json[$key2]);
-		
-		// Get both with newer=0
-		$response = API::userGet(
-			self::$config['userID'],
-			"fulltext?key=" . self::$config['apiKey'] . "&newer=0"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json);
-		$this->assertArrayHasKey($key1, $json);
-		$this->assertEquals($contentVersion1, $json[$key1]);
-		$this->assertArrayHasKey($key1, $json);
-		$this->assertEquals($contentVersion2, $json[$key2]);
-	}
-	
-	
-	public function testSearchItemContent() {
-		$key = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("imported_url", [], $key, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}/fulltext?key=" . self::$config['apiKey']
-		);
-		$this->assert404($response);
-		
-		$content = "Here is some unique full-text content";
-		$pages = 50;
-		
-		// Store content
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}/fulltext?key=" . self::$config['apiKey'],
-			json_encode([
-				"content" => $content,
-				"indexedPages" => $pages,
-				"totalPages" => $pages
-			]),
-			array("Content-Type: application/json")
-		);
-		
-		$this->assert204($response);
-		
-		// Wait for refresh
-		sleep(1);
-		
-		// Search for a word
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=unique&qmode=everything&format=keys"
-			. "&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertEquals($data['key'], trim($response->getBody()));
-		
-		// Search for a phrase
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=unique%20full-text&qmode=everything&format=keys"
-			. "&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertEquals($data['key'], trim($response->getBody()));
-		
-		// Search for nonexistent word
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=nothing&qmode=everything&format=keys"
-			. "&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertEquals("", trim($response->getBody()));
-	}
-	
-	
-	public function testDeleteItemContent() {
-		$key = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("imported_file", [], $key, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		
-		$content = "Ыюм мютат дэбетиз конвынёры эю, ку мэль жкрипта трактатоз.\nПро ут чтэт эрепюят граэкйж, дуо нэ выро рыкючабо пырикюлёз.";
-		
-		// Store content
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}/fulltext?key=" . self::$config['apiKey'],
-			json_encode([
-				"content" => $content,
-				"indexedPages" => 50
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		$contentVersion = $response->getHeader("Last-Modified-Version");
-		
-		// Retrieve it
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}/fulltext?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertEquals($content, $json['content']);
-		$this->assertEquals(50, $json['indexedPages']);
-		
-		// Set to empty string
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}/fulltext?key=" . self::$config['apiKey'],
-			json_encode([
-				"content" => ""
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		$this->assertGreaterThan($contentVersion, $response->getHeader("Last-Modified-Version"));
-		
-		// Make sure it's gone
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}/fulltext?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertEquals("", $json['content']);
-		$this->assertArrayNotHasKey("indexedPages", $json);
-	}
-}
diff --git a/tests/remote/tests/API/2/GeneralTest.php b/tests/remote/tests/API/2/GeneralTest.php
deleted file mode 100644
index c8d7b975..00000000
--- a/tests/remote/tests/API/2/GeneralTest.php
+++ /dev/null
@@ -1,98 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class GeneralTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public function testZoteroWriteToken() {
-		$json = API::getItemTemplate("book");
-		
-		$token = md5(uniqid());
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array(
-				"Content-Type: application/json",
-				"Zotero-Write-Token: $token"
-			)
-		);
-		$this->assert200ForObject($response);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array(
-				"Content-Type: application/json",
-				"Zotero-Write-Token: $token"
-			)
-		);
-		$this->assert412($response);
-	}
-	
-	
-	public function testInvalidCharacters() {
-		$data = array(
-			'title' => "A" . chr(0) . "A",
-			'creators' => array(
-				array(
-					'creatorType' => "author",
-					'name' => "B" . chr(1) . "B"
-				)
-			),
-			'tags' => array(
-				array(
-					'tag' => "C" . chr(2) . "C"
-				)
-			)
-		);
-		$xml = API::createItem("book", $data, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals("AA", $json->title);
-		$this->assertEquals("BB", $json->creators[0]->name);
-		$this->assertEquals("CC", $json->tags[0]->tag);
-	}
-}
diff --git a/tests/remote/tests/API/2/GroupTest.php b/tests/remote/tests/API/2/GroupTest.php
deleted file mode 100644
index 5e236d88..00000000
--- a/tests/remote/tests/API/2/GroupTest.php
+++ /dev/null
@@ -1,142 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API, \SimpleXMLElement;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class GroupTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	
-	/**
-	 * Changing a group's metadata should change its ETag
-	 */
-	public function testUpdateMetadata() {
-		$response = API::userGet(
-			self::$config['userID'],
-			"groups?content=json&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		
-		// Get group API URI and ETag
-		$xml = API::getXMLFromResponse($response);
-		$xml->registerXPathNamespace('atom', 'http://www.w3.org/2005/Atom');
-		$xml->registerXPathNamespace('zapi', 'http://zotero.org/ns/api');
-		$groupID = (string) array_get_first($xml->xpath("//atom:entry/zapi:groupID"));
-		$url = (string) array_get_first($xml->xpath("//atom:entry/atom:link[@rel='self']/@href"));
-		$url = str_replace(self::$config['apiURLPrefix'], '', $url);
-		$etag = (string) array_get_first($xml->xpath("//atom:entry/atom:content/@etag"));
-		
-		// Make sure format=etags returns the same ETag
-		$response = API::userGet(
-			self::$config['userID'],
-			"groups?format=etags&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$this->assertEquals($etag, $json->$groupID);
-		
-		// Update group metadata
-		$json = json_decode(array_get_first($xml->xpath("//atom:entry/atom:content")));
-		$xml = new SimpleXMLElement("<group/>");
-		foreach ($json as $key => $val) {
-			switch ($key) {
-			case 'id':
-			case 'members':
-				continue;
-			
-			case 'name':
-				$name = "My Test Group " . uniqid();
-				$xml['name'] = $name;
-				break;
-			
-			case 'description':
-				$description = "This is a test description " . uniqid();
-				$xml->$key = $description;
-				break;
-			
-			case 'url':
-				$urlField = "http://example.com/" . uniqid();
-				$xml->$key = $urlField;
-				break;
-			
-			default:
-				$xml[$key] = $val;
-			}
-		}
-		$xml = trim(preg_replace('/^<\?xml.+\n/', "", $xml->asXML()));
-		
-		$response = API::put(
-			$url,
-			$xml,
-			array("Content-Type: text/xml"),
-			array(
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			)
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$xml->registerXPathNamespace('zxfer', 'http://zotero.org/ns/transfer');
-		$group = $xml->xpath('//atom:entry/atom:content/zxfer:group');
-		$this->assertCount(1, $group);
-		$this->assertEquals($name, $group[0]['name']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"groups?format=etags&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$newETag = $json->$groupID;
-		$this->assertNotEquals($etag, $newETag);
-		
-		// Check ETag header on individual group request
-		$response = API::groupGet(
-			$groupID,
-			"?content=json&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertEquals($newETag, $response->getHeader('ETag'));
-		$json = json_decode(API::getContentFromResponse($response));
-		$this->assertEquals($name, $json->name);
-		$this->assertEquals($description, $json->description);
-		$this->assertEquals($urlField, $json->url);
-	}
-}
-?>
diff --git a/tests/remote/tests/API/2/ItemTest.php b/tests/remote/tests/API/2/ItemTest.php
deleted file mode 100644
index 6fc60a0b..00000000
--- a/tests/remote/tests/API/2/ItemTest.php
+++ /dev/null
@@ -1,1376 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class ItemTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-	}
-	
-	
-	public function testNewEmptyBookItem() {
-		$xml = API::createItem("book", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals("book", (string) $json->itemType);
-		return $data;
-	}
-	
-	
-	public function testNewEmptyBookItemMultiple() {
-		$json = API::getItemTemplate("book");
-		
-		$data = array();
-		$json->title = "A";
-		$data[] = $json;
-		$json2 = clone $json;
-		$json2->title = "B";
-		$data[] = $json2;
-		$json3 = clone $json;
-		$json3->title = "C";
-		$data[] = $json3;
-		
-		$response = API::postItems($data);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		$xml = API::getItemXML($json['success'], $this);
-		$contents = $xml->xpath('/atom:feed/atom:entry/atom:content');
-		
-		$content = json_decode(array_shift($contents));
-		$this->assertEquals("A", $content->title);
-		$content = json_decode(array_shift($contents));
-		$this->assertEquals("B", $content->title);
-		$content = json_decode(array_shift($contents));
-		$this->assertEquals("C", $content->title);
-	}
-	
-	
-	/**
-	 * @depends testNewEmptyBookItem
-	 */
-	public function testEditBookItem($newItemData) {
-		$key = $newItemData['key'];
-		$version = $newItemData['version'];
-		$json = json_decode($newItemData['content']);
-		
-		$newTitle = "New Title";
-		$numPages = 100;
-		$creatorType = "author";
-		$firstName = "Firstname";
-		$lastName = "Lastname";
-		
-		$json->title = $newTitle;
-		$json->numPages = $numPages;
-		$json->creators[] = array(
-			'creatorType' => $creatorType,
-			'firstName' => $firstName,
-			'lastName' => $lastName
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		$xml = API::getItemXML($key);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$this->assertEquals($newTitle, $json->title);
-		$this->assertEquals($numPages, $json->numPages);
-		$this->assertEquals($creatorType, $json->creators[0]->creatorType);
-		$this->assertEquals($firstName, $json->creators[0]->firstName);
-		$this->assertEquals($lastName, $json->creators[0]->lastName);
-		
-		return API::parseDataFromAtomEntry($xml);
-	}
-	
-	
-	public function testDateAdded() {
-		// In case this is ever extended to other objects
-		$objectType = 'item';
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'item':
-			$itemData = array(
-				"title" => "Test"
-			);
-			$xml = API::createItem("videoRecording", $itemData, $this, 'atom');
-			break;
-		}
-		
-		$newDateAdded = "2013-03-03 21:33:53";
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		$objectKey = $data['key'];
-		$json = json_decode($data['content'], true);
-		
-		$json['title'] = "Test 2";
-		$json['dateAdded'] = $newDateAdded;
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert400($response, "'dateAdded' cannot be modified for existing $objectTypePlural");
-	}
-	
-	
-	public function testDateModified() {
-		// In case this is ever extended to other objects
-		$objectType = 'item';
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'item':
-			$itemData = array(
-				"title" => "Test"
-			);
-			$xml = API::createItem("videoRecording", $itemData, $this, 'atom');
-			break;
-		}
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		$objectKey = $data['key'];
-		$json = json_decode($data['content'], true);
-		$dateModified1 = (string) array_get_first($xml->xpath('//atom:entry/atom:updated'));
-		
-		// Make sure we're in the next second
-		sleep(1);
-		
-		//
-		// If no explicit dateModified, use current timestamp
-		//
-		$json['title'] = "Test 2";
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		switch ($objectType) {
-		case 'item':
-			$xml = API::getItemXML($objectKey);
-			break;
-		}
-		
-		$dateModified2 = (string) array_get_first($xml->xpath('//atom:entry/atom:updated'));
-		$this->assertNotEquals($dateModified1, $dateModified2);
-		$json = json_decode(API::parseDataFromAtomEntry($xml)['content'], true);
-		
-		// Make sure we're in the next second
-		sleep(1);
-		
-		//
-		// If existing dateModified, use current timestamp
-		//
-		$json['title'] = "Test 3";
-		$json['dateModified'] = trim(preg_replace("/[TZ]/", " ", $dateModified2));
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		switch ($objectType) {
-		case 'item':
-			$xml = API::getItemXML($objectKey);
-			break;
-		}
-		
-		$dateModified3 = (string) array_get_first($xml->xpath('//atom:entry/atom:updated'));
-		$this->assertNotEquals($dateModified2, $dateModified3);
-		$json = json_decode(API::parseDataFromAtomEntry($xml)['content'], true);
-		
-		//
-		// If explicit dateModified, use that
-		//
-		$newDateModified = "2013-03-03 21:33:53";
-		$json['title'] = "Test 4";
-		$json['dateModified'] = $newDateModified;
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		switch ($objectType) {
-		case 'item':
-			$xml = API::getItemXML($objectKey);
-			break;
-		}
-		$dateModified4 = (string) array_get_first($xml->xpath('//atom:entry/atom:updated'));
-		$this->assertEquals($newDateModified, trim(preg_replace("/[TZ]/", " ", $dateModified4)));
-	}
-	
-	
-	public function testDateAccessedInvalid() {
-		$date = 'February 1, 2014';
-		$xml = API::createItem("book", array(
-			'accessDate' => $date
-		), $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		// Invalid dates should be ignored
-		$this->assertEmpty($json['accessDate']);
-	}
-	
-	
-	public function testChangeItemType() {
-		$json = API::getItemTemplate("book");
-		$json->title = "Foo";
-		$json->numPages = 100;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$key = API::getFirstSuccessKeyFromResponse($response);
-		$xml = API::getItemXML($key, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$version = $data['version'];
-		$json1 = json_decode($data['content']);
-		
-		$json2 = API::getItemTemplate("bookSection");
-		unset($json2->attachments);
-		unset($json2->notes);
-		
-		foreach ($json2 as $field => &$val) {
-			if ($field != "itemType" && isset($json1->$field)) {
-				$val = $json1->$field;
-			}
-		}
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'],
-			json_encode($json2),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		$xml = API::getItemXML($key);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals("bookSection", $json->itemType);
-		$this->assertEquals("Foo", $json->title);
-		$this->assertObjectNotHasAttribute("numPages", $json);
-	}
-	
-	
-	//
-	// PATCH
-	//
-	public function testModifyItemPartial() {
-		$itemData = array(
-			"title" => "Test"
-		);
-		$xml = API::createItem("book", $itemData, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$itemVersion = $json->itemVersion;
-		
-		$patch = function ($context, $config, $itemKey, $itemVersion, &$itemData, $newData) {
-			foreach ($newData as $field => $val) {
-				$itemData[$field] = $val;
-			}
-			$response = API::userPatch(
-				$config['userID'],
-				"items/$itemKey?key=" . $config['apiKey'],
-				json_encode($newData),
-				array(
-					"Content-Type: application/json",
-					"If-Unmodified-Since-Version: $itemVersion"
-				)
-			);
-			$context->assert204($response);
-			$xml = API::getItemXML($itemKey);
-			$data = API::parseDataFromAtomEntry($xml);
-			$json = json_decode($data['content'], true);
-			
-			foreach ($itemData as $field => $val) {
-				$context->assertEquals($val, $json[$field]);
-			}
-			$headerVersion = $response->getHeader("Last-Modified-Version");
-			$context->assertGreaterThan($itemVersion, $headerVersion);
-			$context->assertEquals($json['itemVersion'], $headerVersion);
-			
-			return $headerVersion;
-		};
-		
-		$newData = array(
-			"date" => "2013"
-		);
-		$itemVersion = $patch($this, self::$config, $data['key'], $itemVersion, $itemData, $newData);
-		
-		$newData = array(
-			"title" => ""
-		);
-		$itemVersion = $patch($this, self::$config, $data['key'], $itemVersion, $itemData, $newData);
-		
-		$newData = array(
-			"tags" => array(
-				array(
-					"tag" => "Foo"
-				)
-			)
-		);
-		$itemVersion = $patch($this, self::$config, $data['key'], $itemVersion, $itemData, $newData);
-		
-		$newData = array(
-			"tags" => array()
-		);
-		$itemVersion = $patch($this, self::$config, $data['key'], $itemVersion, $itemData, $newData);
-		
-		$key = API::createCollection('Test', false, $this, 'key');
-		$newData = array(
-			"collections" => array($key)
-		);
-		$itemVersion = $patch($this, self::$config, $data['key'], $itemVersion, $itemData, $newData);
-		
-		$newData = array(
-			"collections" => array()
-		);
-		$itemVersion = $patch($this, self::$config, $data['key'], $itemVersion, $itemData, $newData);
-	}
-	
-	
-	public function testNewComputerProgramItem() {
-		$xml = API::createItem("computerProgram", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$key = $data['key'];
-		$json = json_decode($data['content']);
-		$this->assertEquals("computerProgram", (string) $json->itemType);
-		
-		$version = "1.0";
-		$json->version = $version;
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: {$data['version']}"
-			)
-		);
-		$this->assert204($response);
-		$xml = API::getItemXML($key);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals($version, $json->version);
-		
-		// 'versionNumber' from v3 should work too
-		unset($json->version);
-		$version = "1.1";
-		$json->versionNumber = $version;
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'],
-			json_encode($json),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert204($response);
-		$xml = API::getItemXML($key);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals($version, $json->version);
-	}
-	
-	
-	public function testNewInvalidBookItem() {
-		$json = API::getItemTemplate("book");
-		
-		// Missing item type
-		$json2 = clone $json;
-		unset($json2->itemType);
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json2)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'itemType' property not provided");
-		
-		// contentType on non-attachment
-		$json2 = clone $json;
-		$json2->contentType = "text/html";
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json2)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'contentType' is valid only for attachment items");
-		
-		// more tests
-	}
-	
-	
-	public function testEditTopLevelNote() {
-		$xml = API::createNoteItem("<p>Test</p>", null, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$noteText = "<p>Test Test</p>";
-		$json['note'] = $noteText;
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertEquals($noteText, $json['note']);
-	}
-	
-	
-	public function testEditChildNote() {
-		$key = API::createItem("book", [ "title" => "Test" ], $this, 'key');
-		$xml = API::createNoteItem("<p>Test</p>", $key, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$noteText = "<p>Test Test</p>";
-		$json['note'] = $noteText;
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertEquals($noteText, $json['note']);
-	}
-	
-	
-	public function testEditTitleWithCollectionInMultipleMode() {
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		
-		$xml = API::createItem("book", [
-			"title" => "A",
-			"collections" => [
-				$collectionKey
-			]
-		], $this, 'atom');
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		$data = json_decode($data['content'], true);
-		$version = $data['itemVersion'];
-		$data['title'] = "B";
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"items" => [$data]
-			])
-		);
-		$this->assert200ForObject($response);
-		
-		$xml = API::getItemXML($data['itemKey']);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertEquals("B", $json['title']);
-		$this->assertGreaterThan($version, $json['itemVersion']);
-	}
-	
-	
-	public function testEditTitleWithTagInMultipleMode() {
-		$tag1 = [
-			"tag" => "foo",
-			"type" => 1
-		];
-		$tag2 = [
-			"tag" => "bar"
-		];
-		
-		$xml = API::createItem("book", [
-			"title" => "A",
-			"tags" => [$tag1]
-		], $this, 'atom');
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertCount(1, $json['tags']);
-		$this->assertEquals($tag1, $json['tags'][0]);
-		
-		$version = $json['itemVersion'];
-		$json['title'] = "B";
-		$json['tags'][] = $tag2;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"items" => [$json]
-			])
-		);
-		$this->assert200ForObject($response);
-		
-		$xml = API::getItemXML($json['itemKey']);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertEquals("B", $json['title']);
-		$this->assertGreaterThan($version, $json['itemVersion']);
-		$this->assertCount(2, $json['tags']);
-		$this->assertContains($tag1, $json['tags']);
-		$this->assertContains($tag2, $json['tags']);
-	}
-	
-	
-	public function testNewTopLevelImportedFileAttachment() {
-		$response = API::get("items/new?itemType=attachment&linkMode=imported_file");
-		$json = json_decode($response->getBody());
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-	}
-	
-	
-	/*
-	Disabled -- see note at Zotero_Item::checkTopLevelAttachment()
-	
-	public function testNewInvalidTopLevelAttachment() {
-		$linkModes = array("linked_url", "imported_url");
-		foreach ($linkModes as $linkMode) {
-			$response = API::get("items/new?itemType=attachment&linkMode=$linkMode");
-			$json = json_decode($response->getBody());
-			
-			$response = API::userPost(
-				self::$config['userID'],
-				"items?key=" . self::$config['apiKey'],
-				json_encode(array(
-					"items" => array($json)
-				)),
-				array("Content-Type: application/json")
-			);
-			$this->assert400ForObject($response, "Only file attachments and PDFs can be top-level items");
-		}
-	}
-	*/
-	
-	
-	public function testNewEmptyLinkAttachmentItem() {
-		$key = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("linked_url", [], $key, $this, 'atom');
-		return API::parseDataFromAtomEntry($xml);
-	}
-	
-	
-	public function testNewEmptyLinkAttachmentItemWithItemKey() {
-		$key = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("linked_url", [], $key, $this, 'atom');
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$json = json_decode($response->getBody());
-		$json->parentItem = $key;
-		require_once '../../model/Utilities.inc.php';
-		require_once '../../model/ID.inc.php';
-		$json->itemKey = \Zotero_ID::getKey();
-		$json->itemVersion = 0;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert200ForObject($response);
-	}
-	
-	
-	public function testNewEmptyImportedURLAttachmentItem() {
-		$key = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("imported_url", [], $key, $this, 'atom');
-		return API::parseDataFromAtomEntry($xml);
-	}
-	
-	
-	public function testEditEmptyLinkAttachmentItem() {
-		$key = API::createItem("book", false, $this, 'key');
-		$xml = API::createAttachmentItem("linked_url", [], $key, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		
-		$key = $data['key'];
-		$version = $data['version'];
-		$json = json_decode($data['content']);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		$xml = API::getItemXML($key);
-		$data = API::parseDataFromAtomEntry($xml);
-		// Item shouldn't change
-		$this->assertEquals($version, $data['version']);
-		
-		return $data;
-	}
-	
-	
-	/**
-	 * @depends testNewEmptyImportedURLAttachmentItem
-	 */
-	public function testEditEmptyImportedURLAttachmentItem($newItemData) {
-		$key = $newItemData['key'];
-		$version = $newItemData['version'];
-		$json = json_decode($newItemData['content']);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		$xml = API::getItemXML($key);
-		$data = API::parseDataFromAtomEntry($xml);
-		// Item shouldn't change
-		$this->assertEquals($version, $data['version']);
-		
-		return $newItemData;
-	}
-	
-	
-	/**
-	 * @depends testEditEmptyLinkAttachmentItem
-	 */
-	public function testEditLinkAttachmentItem($newItemData) {
-		$key = $newItemData['key'];
-		$version = $newItemData['version'];
-		$json = json_decode($newItemData['content']);
-		
-		$contentType = "text/xml";
-		$charset = "utf-8";
-		
-		$json->contentType = $contentType;
-		$json->charset = $charset;
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		$xml = API::getItemXML($key);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals($contentType, $json->contentType);
-		$this->assertEquals($charset, $json->charset);
-	}
-	
-	
-	public function testEditAttachmentUpdatedTimestamp() {
-		$xml = API::createAttachmentItem("linked_file", [], false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$atomUpdated = (string) array_get_first($xml->xpath('//atom:entry/atom:updated'));
-		$json = json_decode($data['content'], true);
-		$json['note'] = "Test";
-		
-		sleep(1);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("If-Unmodified-Since-Version: " . $data['version'])
-		);
-		$this->assert204($response);
-		
-		$xml = API::getItemXML($data['key']);
-		$atomUpdated2 = (string) array_get_first($xml->xpath('//atom:entry/atom:updated'));
-		$this->assertNotEquals($atomUpdated2, $atomUpdated);
-	}
-	
-	
-	public function testNewAttachmentItemInvalidLinkMode() {
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$json = json_decode($response->getBody());
-		
-		// Invalid linkMode
-		$json->linkMode = "invalidName";
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'invalidName' is not a valid linkMode");
-		
-		// Missing linkMode
-		unset($json->linkMode);
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'linkMode' property not provided");
-	}
-	
-	
-	/**
-	 * @depends testNewEmptyBookItem
-	 */
-	public function testNewAttachmentItemMD5OnLinkedURL($newItemData) {
-		$parentKey = $newItemData['key'];
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$json = json_decode($response->getBody());
-		$json->parentItem = $parentKey;
-		
-		$json->md5 = "c7487a750a97722ae1878ed46b215ebe";
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'md5' is valid only for imported attachment items");
-	}
-	
-	
-	/**
-	 * @depends testNewEmptyBookItem
-	 */
-	public function testNewAttachmentItemModTimeOnLinkedURL($newItemData) {
-		$parentKey = $newItemData['key'];
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$json = json_decode($response->getBody());
-		$json->parentItem = $parentKey;
-		
-		$json->mtime = "1332807793000";
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'mtime' is valid only for imported attachment items");
-	}
-	
-	
-	public function test_should_not_allow_changing_storage_properties_in_group_libraries() {
-		$key = API::groupCreateItem(
-			self::$config['ownedPrivateGroupID'], "book", $this, 'key'
-		);
-		$xml = API::groupCreateAttachmentItem(
-			self::$config['ownedPrivateGroupID'], "imported_url", [], $key, $this
-		);
-		$newItemData = API::parseDataFromAtomEntry($xml);
-		
-		$key = $newItemData['key'];
-		$version = $newItemData['version'];
-		$json = json_decode($newItemData['content']);
-		
-		$props = ["md5", "mtime"];
-		foreach ($props as $prop) {
-			$json2 = clone $json;
-			$json2->$prop = "new" . ucwords($prop);
-			$response = API::groupPut(
-				self::$config['ownedPrivateGroupID'],
-				"items/$key?key=" . self::$config['apiKey'],
-				json_encode($json2),
-				array(
-					"Content-Type: application/json",
-					"If-Unmodified-Since-Version: $version"
-				)
-			);
-			$this->assert400($response);
-			$this->assertEquals("Cannot change '$prop' directly in group library", $response->getBody());
-		}
-	}
-	
-	
-	public function testMappedCreatorTypes() {
-		$json = array(
-			"items" => array(
-				array(
-					'itemType' => 'presentation',
-					'title' => 'Test',
-					'creators' => array(
-						array(
-							"creatorType" => "author",
-							"name" => "Foo"
-						)
-					)
-				),
-				array(
-					'itemType' => 'presentation',
-					'title' => 'Test',
-					'creators' => array(
-						array(
-							"creatorType" => "editor",
-							"name" => "Foo"
-						)
-					)
-				)
-			)
-		);
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		// 'author' gets mapped automatically
-		$this->assert200ForObject($response);
-		// Others don't
-		$this->assert400ForObject($response, false, 1);
-	}
-	
-	
-	public function testNumChildren() {
-		$xml = API::createItem("book", false, $this);
-		$this->assertEquals(0, (int) array_get_first($xml->xpath('/atom:entry/zapi:numChildren')));
-		$data = API::parseDataFromAtomEntry($xml);
-		$key = $data['key'];
-		
-		API::createAttachmentItem("linked_url", [], $key, $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('/atom:entry/zapi:numChildren')));
-		
-		API::createNoteItem("Test", $key, $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(2, (int) array_get_first($xml->xpath('/atom:entry/zapi:numChildren')));
-	}
-	
-	
-	public function testTop() {
-		API::userClear(self::$config['userID']);
-		
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		
-		$parentTitle1 = "Parent Title";
-		$childTitle1 = "This is a Test Title";
-		$parentTitle2 = "Another Parent Title";
-		$parentTitle3 = "Yet Another Parent Title";
-		$noteText = "This is a sample note.";
-		$parentTitleSearch = "title";
-		$childTitleSearch = "test";
-		$dates = ["2013", "January 3, 2010", ""];
-		$orderedDates = [$dates[2], $dates[1], $dates[0]];
-		$itemTypes = ["journalArticle", "newspaperArticle", "book"];
-		
-		$parentKeys = [];
-		$childKeys = [];
-		
-		$parentKeys[] = API::createItem($itemTypes[0], [
-			'title' => $parentTitle1,
-			'date' => $dates[0],
-			'collections' => [
-				$collectionKey
-			]
-		], $this, 'key');
-		$childKeys[] = API::createAttachmentItem("linked_url", [
-			'title' => $childTitle1
-		], $parentKeys[0], $this, 'key');
-		
-		$parentKeys[] = API::createItem($itemTypes[1], [
-			'title' => $parentTitle2,
-			'date' => $dates[1]
-		], $this, 'key');
-		$childKeys[] = API::createNoteItem($noteText, $parentKeys[1], $this, 'key');
-		
-		// Create item with deleted child that matches child title search
-		$parentKeys[] = API::createItem($itemTypes[2], [
-			'title' => $parentTitle3
-		], $this, 'key');
-		API::createAttachmentItem("linked_url", [
-			'title' => $childTitle1,
-			'deleted' => true
-		], $parentKeys[sizeOf($parentKeys) - 1], $this, 'key');
-		
-		// Add deleted item with non-deleted child
-		$deletedKey = API::createItem("book", [
-			'title' => "This is a deleted item",
-			'deleted' => true
-		], $this, 'key');
-		API::createNoteItem("This is a child note of a deleted item.", $deletedKey, $this, 'key');
-		
-		// /top, Atom
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		foreach ($parentKeys as $parentKey) {
-			$this->assertContains($parentKey, $xpath);
-		}
-		
-		// /top, Atom, in collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(1, $xpath);
-		$this->assertContains($parentKeys[0], $xpath);
-		
-		// /top, keys
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&format=keys"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(sizeOf($parentKeys), $keys);
-		foreach ($parentKeys as $parentKey) {
-			$this->assertContains($parentKey, $keys);
-		}
-		
-		// /top, keys, in collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?key=" . self::$config['apiKey'] . "&format=keys"
-		);
-		$this->assert200($response);
-		$this->assertEquals($parentKeys[0], trim($response->getBody()));
-		
-		// /top with itemKey for parent, Atom
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&content=json&itemKey=" . $parentKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertEquals($parentKeys[0], (string) array_shift($xpath));
-		
-		// /top with itemKey for parent, Atom, in collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?key=" . self::$config['apiKey']
-				. "&content=json&itemKey=" . $parentKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertEquals($parentKeys[0], (string) array_shift($xpath));
-		
-		// /top with itemKey for parent, keys
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&format=keys&itemKey=" . $parentKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertEquals($parentKeys[0], trim($response->getBody()));
-		
-		// /top with itemKey for parent, keys, in collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?key=" . self::$config['apiKey']
-				. "&format=keys&itemKey=" . $parentKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertEquals($parentKeys[0], trim($response->getBody()));
-		
-		// /top with itemKey for child, Atom
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&content=json&itemKey=" . $childKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertEquals($parentKeys[0], (string) array_shift($xpath));
-		
-		// /top with itemKey for child, keys
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&format=keys&itemKey=" . $childKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertEquals($parentKeys[0], trim($response->getBody()));
-		
-		// /top, Atom, with q for all items
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&content=json&q=$parentTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		foreach ($parentKeys as $parentKey) {
-			$this->assertContains($parentKey, $xpath);
-		}
-		
-		// /top, Atom, in collection, with q for all items
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?key=" . self::$config['apiKey']
-				. "&content=json&q=$parentTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(1, $xpath);
-		$this->assertContains($parentKeys[0], $xpath);
-		
-		// /top, Atom, with q for child item
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&content=json&q=$childTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(1, $xpath);
-		$this->assertContains($parentKeys[0], $xpath);
-		
-		// /top, Atom, in collection, with q for child item
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?key=" . self::$config['apiKey']
-				. "&content=json&q=$childTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-		// Not currently possible
-		/*$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(1, $xpath);
-		$this->assertContains($parentKeys[0], $xpath);*/
-		
-		// /top, Atom, with q for all items, ordered by title
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&content=json&q=$parentTitleSearch"
-				. "&order=title"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/atom:title');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		$orderedTitles = [$parentTitle1, $parentTitle2, $parentTitle3];
-		sort($orderedTitles);
-		$orderedResults = array_map(function ($val) {
-			return (string) $val;
-		}, $xpath);
-		$this->assertEquals($orderedTitles, $orderedResults);
-		
-		// /top, Atom, with q for all items, ordered by date asc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&content=json&q=$parentTitleSearch"
-				. "&order=date&sort=asc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/atom:content');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		$orderedResults = array_map(function ($val) {
-			return json_decode($val)->date;
-		}, $xpath);
-		$this->assertEquals($orderedDates, $orderedResults);
-		
-		// /top, Atom, with q for all items, ordered by date desc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&content=json&q=$parentTitleSearch"
-				. "&order=date&sort=desc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/atom:content');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		$orderedDatesReverse = array_reverse($orderedDates);
-		$orderedResults = array_map(function ($val) {
-			return json_decode($val)->date;
-		}, $xpath);
-		$this->assertEquals($orderedDatesReverse, $orderedResults);
-		
-		// /top, Atom, with q for all items, ordered by item type asc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&content=json&q=$parentTitleSearch"
-				. "&order=itemType"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:itemType');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		$orderedItemTypes = $itemTypes;
-		sort($orderedItemTypes);
-		$orderedResults = array_map(function ($val) {
-			return (string) $val;
-		}, $xpath);
-		$this->assertEquals($orderedItemTypes, $orderedResults);
-		
-		// /top, Atom, with q for all items, ordered by item type desc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&content=json&q=$parentTitleSearch"
-				. "&order=itemType&sort=desc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:itemType');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		$orderedItemTypes = $itemTypes;
-		rsort($orderedItemTypes);
-		$orderedResults = array_map(function ($val) {
-			return (string) $val;
-		}, $xpath);
-		$this->assertEquals($orderedItemTypes, $orderedResults);
-	}
-	
-	
-	public function testParentItem() {
-		$xml = API::createItem("book", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$parentKey = $data['key'];
-		$parentVersion = $data['version'];
-		
-		$xml = API::createAttachmentItem("linked_url", [], $parentKey, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$childKey = $data['key'];
-		$childVersion = $data['version'];
-		
-		$this->assertArrayHasKey('parentItem', $json);
-		$this->assertEquals($parentKey, $json['parentItem']);
-		
-		// Remove the parent, making the child a standalone attachment
-		unset($json['parentItem']);
-		
-		// Remove version property, to test header
-		unset($json['itemVersion']);
-		
-		// The parent item version should have been updated when a child
-		// was added, so this should fail
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$childKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("If-Unmodified-Since-Version: " . $parentVersion)
-		);
-		$this->assert412($response);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$childKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("If-Unmodified-Since-Version: " . $childVersion)
-		);
-		$this->assert204($response);
-		
-		$xml = API::getItemXML($childKey);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertArrayNotHasKey('parentItem', $json);
-	}
-	
-	
-	public function testParentItemPatch() {
-		$xml = API::createItem("book", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$parentKey = $data['key'];
-		$parentVersion = $data['version'];
-		
-		$xml = API::createAttachmentItem("linked_url", [], $parentKey, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$childKey = $data['key'];
-		$childVersion = $data['version'];
-		
-		$this->assertArrayHasKey('parentItem', $json);
-		$this->assertEquals($parentKey, $json['parentItem']);
-		
-		$json = array(
-			'title' => 'Test'
-		);
-		
-		// With PATCH, parent shouldn't be removed even though unspecified
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$childKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("If-Unmodified-Since-Version: " . $childVersion)
-		);
-		$this->assert204($response);
-		
-		$xml = API::getItemXML($childKey);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertArrayHasKey('parentItem', $json);
-	}
-	
-	
-	public function testDate() {
-		$date = "Sept 18, 2012";
-		
-		$xml = API::createItem("book", array(
-			"date" => $date
-		), $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$key = $data['key'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals($date, $json->date);
-		
-		$this->assertEquals('2012', array_get_first($xml->xpath('/atom:entry/zapi:year')));
-	}
-	
-	
-	public function testUnicodeTitle() {
-		$title = "Tést";
-		
-		$xml = API::createItem("book", array("title" => $title), $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$key = $data['key'];
-		
-		// Test entry
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$this->assertContains('"title": "Tést"', $response->getBody());
-		
-		// Test feed
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$this->assertContains('"title": "Tést"', $response->getBody());
-	}
-}
diff --git a/tests/remote/tests/API/2/MappingsTest.php b/tests/remote/tests/API/2/MappingsTest.php
deleted file mode 100644
index 055d8eea..00000000
--- a/tests/remote/tests/API/2/MappingsTest.php
+++ /dev/null
@@ -1,83 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class MappingsTests extends APITests {
-	public function testNewItem() {
-		$response = API::get("items/new?itemType=invalidItemType");
-		$this->assert400($response);
-		
-		$response = API::get("items/new?itemType=book");
-		$this->assert200($response);
-		$this->assertContentType('application/json', $response);
-		$json = json_decode($response->getBody());
-		$this->assertEquals('book', $json->itemType);
-	}
-	
-	
-	public function testNewItemAttachment() {
-		$response = API::get("items/new?itemType=attachment");
-		$this->assert400($response);
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=invalidLinkMode");
-		$this->assert400($response);
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$this->assertNotNull($json);
-		$this->assertObjectHasAttribute('url', $json);
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_file");
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$this->assertNotNull($json);
-		$this->assertObjectNotHasAttribute('url', $json);
-	}
-	
-	
-	public function testComputerProgramVersion() {
-		$response = API::get("items/new?itemType=computerProgram");
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$this->assertObjectHasAttribute('version', $json);
-		$this->assertObjectNotHasAttribute('versionNumber', $json);
-		
-		$response = API::get("itemTypeFields?itemType=computerProgram");
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$fields = array_map(function ($val) {
-			return $val->field;
-		}, $json);
-		$this->assertContains('version', $fields);
-		$this->assertNotContains('versionNumber', $fields);
-	}
-}
-?>
\ No newline at end of file
diff --git a/tests/remote/tests/API/2/NoteTest.php b/tests/remote/tests/API/2/NoteTest.php
deleted file mode 100644
index ec4d6e4f..00000000
--- a/tests/remote/tests/API/2/NoteTest.php
+++ /dev/null
@@ -1,149 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class NoteTests extends APITests {
-	private $content;
-	private $json;
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	
-	public function setUp() {
-		parent::setUp();
-		
-		// Create too-long note content
-		$this->content = str_repeat("1234567890", 25001);
-		
-		// Create JSON template
-		$this->json = API::getItemTemplate("note");
-		$this->json->note = $this->content;
-	}
-	
-	
-	public function testNoteTooLong() {
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($this->json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert413ForObject(
-			$response,
-			"Note '1234567890123456789012345678901234567890123456789012345678901234567890123456789...' too long"
-		);
-	}
-	
-	// Blank first two lines
-	public function testNoteTooLongBlankFirstLines() {
-		$this->json->note = " \n \n" . $this->content;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($this->json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert413ForObject(
-			$response,
-			"Note '1234567890123456789012345678901234567890123456789012345678901234567890123456789...' too long"
-		);
-	}
-	
-	
-	public function testNoteTooLongBlankFirstLinesHTML() {
-		$this->json->note = "\n<p> </p>\n<p> </p>\n" . $this->content;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($this->json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert413ForObject(
-			$response,
-			"Note '1234567890123456789012345678901234567890123456789012345678901234567890123...' too long"
-		);
-	}
-	
-	
-	public function testNoteTooLongTitlePlusNewlines() {
-		$this->json->note = "Full Text:\n\n" . $this->content;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($this->json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert413ForObject(
-			$response,
-			"Note 'Full Text: 1234567890123456789012345678901234567890123456789012345678901234567...' too long"
-		);
-	}
-	
-	
-	// All content within HTML tags
-	public function testNoteTooLongWithinHTMLTags() {
-		$this->json->note = " \n<p><!-- " . $this->content . " --></p>";
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($this->json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert413ForObject(
-			$response,
-			"Note '<p><!-- 1234567890123456789012345678901234567890123456789012345678901234...' too long"
-		);
-	}
-}
-?>
diff --git a/tests/remote/tests/API/2/ObjectTest.php b/tests/remote/tests/API/2/ObjectTest.php
deleted file mode 100644
index 55dd2c17..00000000
--- a/tests/remote/tests/API/2/ObjectTest.php
+++ /dev/null
@@ -1,542 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class ObjectTests extends APITests {
-	public function setUp() {
-		parent::setUp();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public function tearDown() {
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testMultiObjectGet() {
-		$this->_testMultiObjectGet('collection');
-		$this->_testMultiObjectGet('item');
-		$this->_testMultiObjectGet('search');
-	}
-	
-	public function testSingleObjectDelete() {
-		$this->_testSingleObjectDelete('collection');
-		$this->_testSingleObjectDelete('item');
-		$this->_testSingleObjectDelete('search');
-	}
-	
-	
-	public function testMultiObjectDelete() {
-		$this->_testMultiObjectDelete('collection');
-		$this->_testMultiObjectDelete('item');
-		$this->_testMultiObjectDelete('search');
-	}
-	
-	
-	public function testDeleted() {
-		$self = $this;
-		
-		API::userClear(self::$config['userID']);
-		
-		//
-		// Create objects
-		//
-		$objectKeys = array();
-		$objectKeys['tag'] = array("foo", "bar");
-		
-		$objectKeys['collection'][] = API::createCollection("Name", false, $this, 'key');
-		$objectKeys['collection'][] = API::createCollection("Name", false, $this, 'key');
-		$objectKeys['collection'][] = API::createCollection("Name", false, $this, 'key');
-		$objectKeys['item'][] = API::createItem(
-			"book",
-			array(
-				"title" => "Title",
-				"tags" => array_map(function ($tag) {
-					return array("tag" => $tag);
-				}, $objectKeys['tag'])
-			),
-			$this,
-			'key'
-		);
-		$objectKeys['item'][] = API::createItem("book", array("title" => "Title"), $this, 'key');
-		$objectKeys['item'][] = API::createItem("book", array("title" => "Title"), $this, 'key');
-		$objectKeys['search'][] = API::createSearch("Name", 'default', $this, 'key');
-		$objectKeys['search'][] = API::createSearch("Name", 'default', $this, 'key');
-		$objectKeys['search'][] = API::createSearch("Name", 'default', $this, 'key');
-		
-		// Get library version
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&limit=1"
-		);
-		$libraryVersion1 = $response->getHeader("Last-Modified-Version");
-		
-		// Delete first object
-		$config = self::$config;
-		$func = function ($objectType, $libraryVersion) use ($config, $self, $objectKeys) {
-			$objectTypePlural = API::getPluralObjectType($objectType);
-			$keyProp = $objectType . "Key";
-			$response = API::userDelete(
-				$config['userID'],
-				"$objectTypePlural?key=" . $config['apiKey']
-					. "&$keyProp=" . $objectKeys[$objectType][0],
-				array("If-Unmodified-Since-Version: " . $libraryVersion)
-			);
-			$self->assert204($response);
-			return $response->getHeader("Last-Modified-Version");
-		};
-		$tempLibraryVersion = $func('collection', $libraryVersion1);
-		$tempLibraryVersion = $func('item', $tempLibraryVersion);
-		$tempLibraryVersion = $func('search', $tempLibraryVersion);
-		$libraryVersion2 = $tempLibraryVersion;
-		
-		// Delete second and third objects
-		$func = function ($objectType, $libraryVersion) use ($config, $self, $objectKeys) {
-			$objectTypePlural = API::getPluralObjectType($objectType);
-			$keyProp = $objectType . "Key";
-			$response = API::userDelete(
-				$config['userID'],
-				"$objectTypePlural?key=" . $config['apiKey']
-					. "&$keyProp=" . implode(',', array_slice($objectKeys[$objectType], 1)),
-				array("If-Unmodified-Since-Version: " . $libraryVersion)
-			);
-			$self->assert204($response);
-			return $response->getHeader("Last-Modified-Version");
-		};
-		$tempLibraryVersion = $func('collection', $tempLibraryVersion);
-		$tempLibraryVersion = $func('item', $tempLibraryVersion);
-		$libraryVersion3 = $func('search', $tempLibraryVersion);
-		
-		
-		// Request all deleted objects
-		$response = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey'] . "&newer=$libraryVersion1"
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertNotNull($version);
-		$this->assertContentType("application/json", $response);
-		
-		// Verify keys
-		$func = function ($json, $objectType, $objectKeys) use ($self) {
-			$objectTypePlural = API::getPluralObjectType($objectType);
-			$self->assertArrayHasKey($objectTypePlural, $json);
-			$self->assertCount(sizeOf($objectKeys), $json[$objectTypePlural]);
-			foreach ($objectKeys as $key) {
-				$self->assertContains($key, $json[$objectTypePlural]);
-			}
-		};
-		$func($json, 'collection', $objectKeys['collection']);
-		$func($json, 'item', $objectKeys['item']);
-		$func($json, 'search', $objectKeys['search']);
-		// Tags aren't deleted by removing from items
-		$func($json, 'tag', []);
-		
-		
-		// Request second and third deleted objects
-		$response = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey'] . "&newer=$libraryVersion2"
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertNotNull($version);
-		$this->assertContentType("application/json", $response);
-		
-		// Verify keys
-		$func = function ($json, $objectType, $objectKeys) use ($self) {
-			$objectTypePlural = API::getPluralObjectType($objectType);
-			$self->assertArrayHasKey($objectTypePlural, $json);
-			$self->assertCount(sizeOf($objectKeys), $json[$objectTypePlural]);
-			foreach ($objectKeys as $key) {
-				$self->assertContains($key, $json[$objectTypePlural]);
-			}
-		};
-		$func($json, 'collection', array_slice($objectKeys['collection'], 1));
-		$func($json, 'item', array_slice($objectKeys['item'], 1));
-		$func($json, 'search', array_slice($objectKeys['search'], 1));
-		// Tags aren't deleted by removing from items
-		$func($json, 'tag', []);
-		
-		
-		// Explicit tag deletion
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey']
-			. "&tag=" . implode('%20||%20', $objectKeys['tag']),
-			array("If-Unmodified-Since-Version: " . $libraryVersion3)
-		);
-		$self->assert204($response);
-		
-		// Verify deleted tags
-		$response = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey'] . "&newer=$libraryVersion3"
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$func($json, 'tag', $objectKeys['tag']);
-	}
-	
-	
-	public function testPartialWriteFailure() {
-		$this->_testPartialWriteFailure('collection');
-		$this->_testPartialWriteFailure('item');
-		$this->_testPartialWriteFailure('search');
-	}
-	
-	
-	public function testPartialWriteFailureWithUnchanged() {
-		$this->_testPartialWriteFailureWithUnchanged('collection');
-		$this->_testPartialWriteFailureWithUnchanged('item');
-		$this->_testPartialWriteFailureWithUnchanged('search');
-	}
-	
-	
-	public function testMultiObjectWriteInvalidObject() {
-		$this->_testMultiObjectWriteInvalidObject('collection');
-		$this->_testMultiObjectWriteInvalidObject('item');
-		$this->_testMultiObjectWriteInvalidObject('search');
-	}
-	
-	
-	private function _testMultiObjectGet($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$keyProp = $objectType . "Key";
-		
-		$keys = [];
-		switch ($objectType) {
-		case 'collection':
-			$keys[] = API::createCollection("Name", false, $this, 'key');
-			$keys[] = API::createCollection("Name", false, $this, 'key');
-			API::createCollection("Name", false, $this, 'key');
-			break;
-		
-		case 'item':
-			$keys[] = API::createItem("book", array("title" => "Title"), $this, 'key');
-			$keys[] = API::createItem("book", array("title" => "Title"), $this, 'key');
-			API::createItem("book", array("title" => "Title"), $this, 'key');
-			break;
-		
-		case 'search':
-			$keys[] = API::createSearch("Name", 'default', $this, 'key');
-			$keys[] = API::createSearch("Name", 'default', $this, 'key');
-			API::createSearch("Name", 'default', $this, 'key');
-			break;
-		}
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&$keyProp=" . implode(',', $keys)
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($keys), $response);
-		
-		// Trailing comma in itemKey parameter
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&$keyProp=" . implode(',', $keys) . ","
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($keys), $response);
-	}
-	
-	
-	private function _testSingleObjectDelete($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'collection':
-			$xml = API::createCollection("Name", false, $this);
-			break;
-		
-		case 'item':
-			$xml = API::createItem("book", array("title" => "Title"), $this);
-			break;
-		
-		case 'search':
-			$xml = API::createSearch("Name", 'default', $this);
-			break;
-		}
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		$objectKey = $data['key'];
-		$objectVersion = $data['version'];
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'],
-			array(
-				"If-Unmodified-Since-Version: " . $objectVersion
-			)
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey']
-		);
-		$this->assert404($response);
-	}
-	
-	
-	private function _testMultiObjectDelete($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$keyProp = $objectType . "Key";
-		
-		$deleteKeys = array();
-		$keepKeys = array();
-		switch ($objectType) {
-		case 'collection':
-			$deleteKeys[] = API::createCollection("Name", false, $this, 'key');
-			$deleteKeys[] = API::createCollection("Name", false, $this, 'key');
-			$keepKeys[] = API::createCollection("Name", false, $this, 'key');
-			break;
-		
-		case 'item':
-			$deleteKeys[] = API::createItem("book", array("title" => "Title"), $this, 'key');
-			$deleteKeys[] = API::createItem("book", array("title" => "Title"), $this, 'key');
-			$keepKeys[] = API::createItem("book", array("title" => "Title"), $this, 'key');
-			break;
-		
-		case 'search':
-			$deleteKeys[] = API::createSearch("Name", 'default', $this, 'key');
-			$deleteKeys[] = API::createSearch("Name", 'default', $this, 'key');
-			$keepKeys[] = API::createSearch("Name", 'default', $this, 'key');
-			break;
-		}
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($deleteKeys) + sizeOf($keepKeys), $response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&$keyProp=" . implode(',', $deleteKeys),
-			array(
-				"If-Unmodified-Since-Version: " . $libraryVersion
-			)
-		);
-		$this->assert204($response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($keepKeys), $response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&$keyProp=" . implode(',', $keepKeys)
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($keepKeys), $response);
-		
-		// Add trailing comma to itemKey param, to test key parsing
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&$keyProp=" . implode(',', $keepKeys) . ",",
-			array(
-				"If-Unmodified-Since-Version: " . $libraryVersion
-			)
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-	}
-	
-	
-	private function _testPartialWriteFailure($objectType) {
-		API::userClear(self::$config['userID']);
-		
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'collection':
-			$json1 = array("name" => "Test");
-			$json2 = array("name" => str_repeat("1234567890", 6554));
-			$json3 = array("name" => "Test");
-			break;
-		
-		case 'item':
-			$json1 = API::getItemTemplate('book');
-			$json2 = clone $json1;
-			$json3 = clone $json1;
-			$json2->title = str_repeat("1234567890", 6554);
-			break;
-		
-		case 'search':
-			$conditions = array(
-				array(
-					'condition' => 'title',
-					'operator' => 'contains',
-					'value' => 'value'
-				)
-			);
-			$json1 = array("name" => "Test", "conditions" => $conditions);
-			$json2 = array("name" => str_repeat("1234567890", 6554), "conditions" => $conditions);
-			$json3 = array("name" => "Test", "conditions" => $conditions);
-			break;
-		}
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"$objectTypePlural" => array($json1, $json2, $json3)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assert200ForObject($response, false, 0);
-		$this->assert413ForObject($response, false, 1);
-		$this->assert200ForObject($response, false, 2);
-		$json = API::getJSONFromResponse($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?format=keys&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		foreach ($json['success'] as $key) {
-			$this->assertContains($key, $keys);
-		}
-	}
-	
-	
-	private function _testPartialWriteFailureWithUnchanged($objectType) {
-		API::userClear(self::$config['userID']);
-		
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'collection':
-			$data = API::createCollection("Test", false, $this, 'data');
-			$json1 = json_decode($data['content']);
-			$json2 = array("name" => str_repeat("1234567890", 6554));
-			$json3 = array("name" => "Test");
-			break;
-		
-		case 'item':
-			$data = API::createItem("book", array("title" => "Title"), $this, 'data');
-			$json1 = json_decode($data['content']);
-			$json2 = API::getItemTemplate('book');
-			$json3 = clone $json2;
-			$json2->title = str_repeat("1234567890", 6554);
-			break;
-		
-		case 'search':
-			$conditions = array(
-				array(
-					'condition' => 'title',
-					'operator' => 'contains',
-					'value' => 'value'
-				)
-			);
-			$data = API::createSearch("Name", $conditions, $this, 'data');
-			$json1 = json_decode($data['content']);
-			$json2 = array("name" => str_repeat("1234567890", 6554), "conditions" => $conditions);
-			$json3 = array("name" => "Test", "conditions" => $conditions);
-			break;
-		}
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"$objectTypePlural" => array($json1, $json2, $json3)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertUnchangedForObject($response, false, 0);
-		$this->assert413ForObject($response, false, 1);
-		$this->assert200ForObject($response, false, 2);
-		$json = API::getJSONFromResponse($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?format=keys&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		foreach ($json['success'] as $key) {
-			$this->assertContains($key, $keys);
-		}
-	}
-	
-	
-	private function _testMultiObjectWriteInvalidObject($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'],
-			json_encode([[]]),
-			array("Content-Type: application/json")
-		);
-		$this->assert400($response, "Uploaded data must be a JSON object");
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"$objectTypePlural" => array(
-					"foo" => "bar"
-				)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert400($response, "'$objectTypePlural' must be an array");
-	}
-}
diff --git a/tests/remote/tests/API/2/ParamsTest.php b/tests/remote/tests/API/2/ParamsTest.php
deleted file mode 100644
index a15ebe24..00000000
--- a/tests/remote/tests/API/2/ParamsTest.php
+++ /dev/null
@@ -1,345 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class ParamsTests extends APITests {
-	private static $collectionKeys = array();
-	private static $itemKeys = array();
-	private static $searchKeys = array();
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function setUp() {
-		parent::setUp();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testFormatKeys() {
-		//
-		// Collections
-		//
-		for ($i=0; $i<5; $i++) {
-			self::$collectionKeys[] = API::createCollection("Test", false, null, 'key');
-		}
-		
-		//
-		// Items
-		//
-		for ($i=0; $i<5; $i++) {
-			self::$itemKeys[] = API::createItem("book", false, null, 'key');
-		}
-		self::$itemKeys[] = API::createAttachmentItem("imported_file", [], false, null, 'key');
-		
-		//
-		// Searches
-		//
-		for ($i=0; $i<5; $i++) {
-			self::$searchKeys[] = API::createSearch("Test", 'default', null, 'key');
-		}
-		
-		$this->_testFormatKeys('collection');
-		$this->_testFormatKeys('item');
-		$this->_testFormatKeys('search');
-		
-		$this->_testFormatKeysSorted('collection');
-		$this->_testFormatKeysSorted('item');
-		$this->_testFormatKeysSorted('search');
-	}
-	
-	
-	private function _testFormatKeys($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$keysVar = $objectType . "Keys";
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&format=keys"
-		);
-		$this->assert200($response);
-		
-		$keys = explode("\n", trim($response->getBody()));
-		sort($keys);
-		$this->assertEmpty(
-			array_merge(
-				array_diff(self::$$keysVar, $keys), array_diff($keys, self::$$keysVar)
-			)
-		);
-	}
-	
-	
-	private function _testFormatKeysSorted($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$keysVar = $objectType . "Keys";
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&format=keys&order=title"
-		);
-		$this->assert200($response);
-		
-		$keys = explode("\n", trim($response->getBody()));
-		sort($keys);
-		$this->assertEmpty(
-			array_merge(
-				array_diff(self::$$keysVar, $keys), array_diff($keys, self::$$keysVar)
-			)
-		);
-	}
-	
-	
-	public function testObjectKeyParameter() {
-		$this->_testObjectKeyParameter('collection');
-		$this->_testObjectKeyParameter('item');
-		$this->_testObjectKeyParameter('search');
-	}
-	
-	
-	private function _testObjectKeyParameter($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$xmlArray = array();
-		
-		switch ($objectType) {
-		case 'collection':
-			$xmlArray[] = API::createCollection("Name", false, $this);
-			$xmlArray[] = API::createCollection("Name", false, $this);
-			break;
-		
-		case 'item':
-			$xmlArray[] = API::createItem("book", false, $this);
-			$xmlArray[] = API::createItem("book", false, $this);
-			break;
-		
-		case 'search':
-			$xmlArray[] = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this
-			);
-			$xmlArray[] = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this
-			);
-			break;
-		}
-		
-		$keys = array();
-		foreach ($xmlArray as $xml) {
-			$data = API::parseDataFromAtomEntry($xml);
-			$keys[] = $data['key'];
-		}
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&content=json&{$objectType}Key={$keys[0]}"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$this->assertEquals($keys[0], $data['key']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&content=json&{$objectType}Key={$keys[0]},{$keys[1]}&order={$objectType}KeyList"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(2, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$key = (string) array_shift($xpath);
-		$this->assertEquals($keys[0], $key);
-		$key = (string) array_shift($xpath);
-		$this->assertEquals($keys[1], $key);
-	}
-	
-	
-	public function testCollectionQuickSearch() {
-		$title1 = "Test Title";
-		$title2 = "Another Title";
-		
-		$keys = [];
-		$keys[] = API::createCollection($title1, [], $this, 'key');
-		$keys[] = API::createCollection($title2, [], $this, 'key');
-		
-		// Search by title
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'] . "&content=json&q=another"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$key = (string) array_shift($xpath);
-		$this->assertEquals($keys[1], $key);
-		
-		// No results
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'] . "&content=json&q=nothing"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-	}
-	
-	
-	public function testItemQuickSearch() {
-		$title1 = "Test Title";
-		$title2 = "Another Title";
-		$year2 = "2013";
-		
-		$keys = [];
-		$keys[] = API::createItem("book", [
-			'title' => $title1
-		], $this, 'key');
-		$keys[] = API::createItem("journalArticle", [
-			'title' => $title2,
-			'date' => "November 25, $year2"
-		], $this, 'key');
-		
-		// Search by title
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&content=json&q=" . urlencode($title1)
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$key = (string) array_shift($xpath);
-		$this->assertEquals($keys[0], $key);
-		
-		// TODO: Search by creator
-		
-		// Search by year
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&content=json&q=$year2"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$key = (string) array_shift($xpath);
-		$this->assertEquals($keys[1], $key);
-		
-		// Search by year + 1
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&content=json&q=" . ($year2 + 1)
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-	}
-	
-	
-	public function testItemQuickSearchOrderByDate() {
-		$title1 = "Test Title";
-		$title2 = "Another Title";
-		
-		$keys = [];
-		$keys[] = API::createItem("book", [
-			'title' => $title1,
-			'date' => "February 12, 2013"
-		], $this, 'key');
-		$keys[] = API::createItem("journalArticle", [
-			'title' => $title2,
-			'date' => "November 25, 2012"
-		], $this, 'key');
-		
-		// Search for one by title
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&content=json&q=" . urlencode($title1)
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$key = (string) array_shift($xpath);
-		$this->assertEquals($keys[0], $key);
-		
-		// Search by both by title, date asc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&content=json&q=title&order=date&sort=asc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(2, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$key = (string) array_shift($xpath);
-		$this->assertEquals($keys[1], $key);
-		$key = (string) array_shift($xpath);
-		$this->assertEquals($keys[0], $key);
-		
-		// Search by both by title, date desc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&content=json&q=title&order=date&sort=desc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(2, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$key = (string) array_shift($xpath);
-		$this->assertEquals($keys[0], $key);
-		$key = (string) array_shift($xpath);
-		$this->assertEquals($keys[1], $key);
-	}
-}
diff --git a/tests/remote/tests/API/2/PermissionsTest.php b/tests/remote/tests/API/2/PermissionsTest.php
deleted file mode 100644
index 8236a389..00000000
--- a/tests/remote/tests/API/2/PermissionsTest.php
+++ /dev/null
@@ -1,306 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class PermissionsTest extends APITests {
-	public function tearDown() {
-		API::setKeyOption(
-			self::$config['userID'], self::$config['apiKey'], 'libraryWrite', 1
-		);
-	}
-	
-	
-	public function testUserGroupsAnonymous() {
-		$response = API::get("users/" . self::$config['userID'] . "/groups?content=json");
-		$this->assert200($response);
-		
-		$this->assertTotalResults(self::$config['numPublicGroups'], $response);
-		
-		// Make sure they're the right groups
-		$xml = API::getXMLFromResponse($response);
-		$groupIDs = array_map(function ($id) {
-			return (int) $id;
-		}, $xml->xpath('//atom:entry/zapi:groupID'));
-		$this->assertContains(self::$config['ownedPublicGroupID'], $groupIDs);
-		$this->assertContains(self::$config['ownedPublicNoAnonymousGroupID'], $groupIDs);
-	}
-	
-	
-	public function testUserGroupsOwned() {
-		$response = API::get(
-			"users/" . self::$config['userID'] . "/groups?content=json"
-			. "&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		
-		$this->assertNumResults(self::$config['numOwnedGroups'], $response);
-		$this->assertTotalResults(self::$config['numOwnedGroups'], $response);
-	}
-	
-	
-	/**
-	 * A key without note access shouldn't be able to create a note
-	 */
-	/*public function testKeyNoteAccessWriteError() {
-		API::setKeyOption(
-			self::$config['userID'], self::$config['apiKey'], 'libraryNotes', 0
-		);
-		
-		$response = API::get("items/new?itemType=note");
-		$json = json_decode($response->getBody());
-		$json->note = "Test";
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert403($response);
-	}*/
-	
-	
-	public function testKeyNoteAccess() {
-		API::userClear(self::$config['userID']);
-		
-		API::setKeyOption(
-			self::$config['userID'], self::$config['apiKey'], 'libraryNotes', 1
-		);
-		
-		$keys = array();
-		$topLevelKeys = array();
-		$bookKeys = array();
-		
-		$xml = API::createItem('book', array("title" => "A"), $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$keys[] = $data['key'];
-		$topKeys[] = $data['key'];
-		$bookKeys[] = $data['key'];
-		
-		$xml = API::createNoteItem("<p>B</p>", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$keys[] = $data['key'];
-		$topKeys[] = $data['key'];
-		
-		$xml = API::createNoteItem("<p>C</p>", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$keys[] = $data['key'];
-		$topKeys[] = $data['key'];
-		
-		$xml = API::createNoteItem("<p>D</p>", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$keys[] = $data['key'];
-		$topKeys[] = $data['key'];
-		
-		$xml = API::createNoteItem("<p>E</p>", false, $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$keys[] = $data['key'];
-		$topKeys[] = $data['key'];
-		
-		$xml = API::createItem('book', array("title" => "F"), $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$keys[] = $data['key'];
-		$topKeys[] = $data['key'];
-		$bookKeys[] = $data['key'];
-		
-		$xml = API::createNoteItem("<p>G</p>", $data['key'], $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$keys[] = $data['key'];
-		
-		// Create collection and add items to it
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'],
-			json_encode(array(
-				"collections" => array(
-					array(
-						"name" => "Test",
-						"parentCollection" => false
-					)
-				)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert200ForObject($response);
-		$collectionKey = API::getFirstSuccessKeyFromResponse($response);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections/$collectionKey/items?key=" . self::$config['apiKey'],
-			implode(" ", $topKeys)
-		);
-		$this->assert204($response);
-		
-		//
-		// format=atom
-		//
-		// Root
-		$response = API::userGet(
-			self::$config['userID'], "items?key=" . self::$config['apiKey']
-		);
-		$this->assertNumResults(sizeOf($keys), $response);
-		$this->assertTotalResults(sizeOf($keys), $response);
-		
-		// Top
-		$response = API::userGet(
-			self::$config['userID'], "items/top?key=" . self::$config['apiKey']
-		);
-		$this->assertNumResults(sizeOf($topKeys), $response);
-		$this->assertTotalResults(sizeOf($topKeys), $response);
-		
-		// Collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?key=" . self::$config['apiKey']
-		);
-		$this->assertNumResults(sizeOf($topKeys), $response);
-		$this->assertTotalResults(sizeOf($topKeys), $response);
-		
-		//
-		// format=keys
-		//
-		// Root
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys"
-		);
-		$this->assert200($response);
-		$this->assertCount(sizeOf($keys), explode("\n", trim($response->getBody())));
-		
-		// Top
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&format=keys"
-		);
-		$this->assert200($response);
-		$this->assertCount(sizeOf($topKeys), explode("\n", trim($response->getBody())));
-		
-		// Collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?key=" . self::$config['apiKey'] . "&format=keys"
-		);
-		$this->assert200($response);
-		$this->assertCount(sizeOf($topKeys), explode("\n", trim($response->getBody())));
-		
-		// Remove notes privilege from key
-		API::setKeyOption(
-			self::$config['userID'], self::$config['apiKey'], 'libraryNotes', 0
-		);
-		
-		//
-		// format=atom
-		//
-		// totalResults with limit
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&limit=1"
-		);
-		$this->assertNumResults(1, $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		// And without limit
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey']
-		);
-		$this->assertNumResults(sizeOf($bookKeys), $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		// Top
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey']
-		);
-		$this->assertNumResults(sizeOf($bookKeys), $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		// Collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items?key=" . self::$config['apiKey']
-		);
-		$this->assertNumResults(sizeOf($bookKeys), $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		//
-		// format=keys
-		//
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys"
-		);
-		$keys = explode("\n", trim($response->getBody()));
-		sort($keys);
-		$this->assertEmpty(
-			array_merge(
-				array_diff($bookKeys, $keys), array_diff($keys, $bookKeys)
-			)
-		);
-	}
-	
-	
-	public function testTagDeletePermissions() {
-		API::userClear(self::$config['userID']);
-		
-		$xml = API::createItem('book', array(
-			"tags" => array(
-				array(
-					"tag" => "A"
-				)
-			)
-		), $this);
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		API::setKeyOption(
-			self::$config['userID'], self::$config['apiKey'], 'libraryWrite', 0
-		);
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?tag=A&key=" . self::$config['apiKey']
-		);
-		$this->assert403($response);
-		
-		API::setKeyOption(
-			self::$config['userID'], self::$config['apiKey'], 'libraryWrite', 1
-		);
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?tag=A&key=" . self::$config['apiKey'],
-			array("If-Unmodified-Since-Version: $libraryVersion")
-		);
-		$this->assert204($response);
-	}
-}
diff --git a/tests/remote/tests/API/2/RelationTest.php b/tests/remote/tests/API/2/RelationTest.php
deleted file mode 100644
index 9bcda45b..00000000
--- a/tests/remote/tests/API/2/RelationTest.php
+++ /dev/null
@@ -1,321 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class RelationTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testNewItemRelations() {
-		$relations = array(
-			"owl:sameAs" => "http://zotero.org/groups/1/items/AAAAAAAA",
-			"dc:relation" => array(
-				"http://zotero.org/users/" . self::$config['userID'] . "/items/AAAAAAAA",
-				"http://zotero.org/users/" . self::$config['userID'] . "/items/BBBBBBBB",
-			)
-		);
-		
-		$xml = API::createItem("book", array(
-			"relations" => $relations
-		), $this);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertCount(sizeOf($relations), $json['relations']);
-		foreach ($relations as $predicate => $object) {
-			if (is_string($object)) {
-				$this->assertEquals($object, $json['relations'][$predicate]);
-			}
-			else {
-				foreach ($object as $rel) {
-					$this->assertContains($rel, $json['relations'][$predicate]);
-				}
-			}
-		}
-	}
-	
-	
-	public function testRelatedItemRelations() {
-		$relations = [
-			"owl:sameAs" => "http://zotero.org/groups/1/items/AAAAAAAA",
-		];
-		
-		$item1JSON = API::createItem("book", [
-			"relations" => $relations
-		], $this, 'json');
-		$item2JSON = API::createItem("book", null, $this, 'json');
-		
-		$uriPrefix = "http://zotero.org/users/" . self::$config['userID'] . "/items/";
-		$item1URI = $uriPrefix . $item1JSON['itemKey'];
-		$item2URI = $uriPrefix . $item2JSON['itemKey'];
-		
-		// Add item 2 as related item of item 1
-		$relations["dc:relation"] = $item2URI;
-		$item1JSON["relations"] = $relations;
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$item1JSON['itemKey']}?key=" . self::$config['apiKey'],
-			json_encode($item1JSON)
-		);
-		$this->assert204($response);
-		
-		// Make sure it exists on item 1
-		$xml = API::getItemXML($item1JSON['itemKey']);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertCount(sizeOf($relations), $json['relations']);
-		foreach ($relations as $predicate => $object) {
-			$this->assertEquals($object, $json['relations'][$predicate]);
-		}
-		
-		// And item 2, since related items are bidirectional
-		$xml = API::getItemXML($item2JSON['itemKey']);
-		$data = API::parseDataFromAtomEntry($xml);
-		$item2JSON = json_decode($data['content'], true);
-		$this->assertCount(1, $item2JSON['relations']);
-		$this->assertEquals($item1URI, $item2JSON["relations"]["dc:relation"]);
-		
-		// Sending item 2's unmodified JSON back up shouldn't cause the item to be updated.
-		// Even though we're sending a relation that's technically not part of the item,
-		// when it loads the item it will load the reverse relations too and therefore not
-		// add a relation that it thinks already exists.
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$item2JSON['itemKey']}?key=" . self::$config['apiKey'],
-			json_encode($item2JSON)
-		);
-		$this->assert204($response);
-		$this->assertEquals($item2JSON['itemVersion'], $response->getHeader("Last-Modified-Version"));
-	}
-	
-	
-	// Same as above, but in a single request
-	public function testRelatedItemRelationsSingleRequest() {
-		$uriPrefix = "http://zotero.org/users/" . self::$config['userID'] . "/items/";
-		// TEMP: Use autoloader
-		require_once '../../model/Utilities.inc.php';
-		require_once '../../model/ID.inc.php';
-		$item1Key = \Zotero_ID::getKey();
-		$item2Key = \Zotero_ID::getKey();
-		$item1URI = $uriPrefix . $item1Key;
-		$item2URI = $uriPrefix . $item2Key;
-		
-		$item1JSON = API::getItemTemplate('book');
-		$item1JSON->itemKey = $item1Key;
-		$item1JSON->itemVersion = 0;
-		$item1JSON->relations->{'dc:relation'} = $item2URI;
-		$item2JSON = API::getItemTemplate('book');
-		$item2JSON->itemKey = $item2Key;
-		$item2JSON->itemVersion = 0;
-		
-		$response = API::postItems([$item1JSON, $item2JSON]);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		// Make sure it exists on item 1
-		$xml = API::getItemXML($item1JSON->itemKey);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertCount(1, $json['relations']);
-		$this->assertEquals($item2URI, $json['relations']['dc:relation']);
-		
-		// And item 2, since related items are bidirectional
-		$xml = API::getItemXML($item2JSON->itemKey);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertCount(1, $json['relations']);
-		$this->assertEquals($item1URI, $json['relations']['dc:relation']);
-	}
-	
-	
-	public function testInvalidItemRelation() {
-		$response = API::createItem("book", array(
-			"relations" => array(
-				"foo:unknown" => "http://zotero.org/groups/1/items/AAAAAAAA"
-			)
-		), $this, 'response');
-		$this->assert400ForObject($response, "Unsupported predicate 'foo:unknown'");
-		
-		$response = API::createItem("book", array(
-			"relations" => array(
-				"owl:sameAs" => "Not a URI"
-			)
-		), $this, 'response');
-		$this->assert400ForObject($response, "'relations' values currently must be Zotero item URIs");
-		
-		$response = API::createItem("book", array(
-			"relations" => array(
-				"owl:sameAs" => ["Not a URI"]
-			)
-		), $this, 'response');
-		$this->assert400ForObject($response, "'relations' values currently must be Zotero item URIs");
-	}
-	
-	
-	public function testDeleteItemRelation() {
-		$relations = array(
-			"owl:sameAs" => [
-				"http://zotero.org/groups/1/items/AAAAAAAA",
-				"http://zotero.org/groups/1/items/BBBBBBBB"
-			],
-			"dc:relation" => "http://zotero.org/users/"
-				. self::$config['userID'] . "/items/AAAAAAAA",
-		);
-		
-		$data = API::createItem("book", array(
-			"relations" => $relations
-		), $this, 'data');
-		
-		$json = json_decode($data['content'], true);
-		
-		// Remove a relation
-		$json['relations']['owl:sameAs'] = $relations['owl:sameAs'] = $relations['owl:sameAs'][0];
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		// Make sure it's gone
-		$xml = API::getItemXML($data['key']);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertCount(sizeOf($relations), $json['relations']);
-		foreach ($relations as $predicate => $object) {
-			$this->assertEquals($object, $json['relations'][$predicate]);
-		}
-		
-		// Delete all
-		$json['relations'] = new \stdClass;
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		// Make sure they're gone
-		$xml = API::getItemXML($data['key']);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertCount(0, $json['relations']);
-	}
-	
-	
-	//
-	// Collections
-	//
-	public function testNewCollectionRelations() {
-		$relations = array(
-			"owl:sameAs" => "http://zotero.org/groups/1/collections/AAAAAAAA"
-		);
-		
-		$data = API::createCollection("Test", array(
-			"relations" => $relations
-		), $this, 'data');
-		$json = json_decode($data['content'], true);
-		$this->assertCount(sizeOf($relations), $json['relations']);
-		foreach ($relations as $predicate => $object) {
-			$this->assertEquals($object, $json['relations'][$predicate]);
-		}
-	}
-	
-	
-	public function testInvalidCollectionRelation() {
-		$json = array(
-			"name" => "Test",
-			"relations" => array(
-				"foo:unknown" => "http://zotero.org/groups/1/collections/AAAAAAAA"
-			)
-		);
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'],
-			json_encode(array("collections" => array($json)))
-		);
-		$this->assert400ForObject($response, "Unsupported predicate 'foo:unknown'");
-		
-		$json["relations"] = array(
-			"owl:sameAs" => "Not a URI"
-		);
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'],
-			json_encode(array("collections" => array($json)))
-		);
-		$this->assert400ForObject($response, "'relations' values currently must be Zotero collection URIs");
-		
-		$json["relations"] = ["http://zotero.org/groups/1/collections/AAAAAAAA"];
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections?key=" . self::$config['apiKey'],
-			json_encode(array("collections" => array($json)))
-		);
-		$this->assert400ForObject($response, "'relations' property must be an object");
-	}
-	
-	
-	public function testDeleteCollectionRelation() {
-		$relations = array(
-			"owl:sameAs" => "http://zotero.org/groups/1/collections/AAAAAAAA"
-		);
-		$data = API::createCollection("Test", array(
-			"relations" => $relations
-		), $this, 'data');
-		$json = json_decode($data['content'], true);
-		
-		// Remove all relations
-		$json['relations'] = new \stdClass;
-		unset($relations['owl:sameAs']);
-		$response = API::userPut(
-			self::$config['userID'],
-			"collections/{$data['key']}?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		// Make sure it's gone
-		$xml = API::getCollectionXML($data['key']);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertCount(sizeOf($relations), $json['relations']);
-		foreach ($relations as $predicate => $object) {
-			$this->assertEquals($object, $json['relations'][$predicate]);
-		}
-	}
-}
diff --git a/tests/remote/tests/API/2/SearchTest.php b/tests/remote/tests/API/2/SearchTest.php
deleted file mode 100644
index c41908a9..00000000
--- a/tests/remote/tests/API/2/SearchTest.php
+++ /dev/null
@@ -1,199 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class SearchTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testNewSearch() {
-		$name = "Test Search";
-		$conditions = array(
-			array(
-				"condition" => "title",
-				"operator" => "contains",
-				"value" => "test"
-			),
-			array(
-				"condition" => "noChildren",
-				"operator" => "false",
-				"value" => ""
-			),
-			array(
-				"condition" => "fulltextContent/regexp",
-				"operator" => "contains",
-				"value" => "/test/"
-			)
-		);
-		
-		$xml = API::createSearch($name, $conditions, $this);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals($name, (string) $json->name);
-		$this->assertInternalType('array', $json->conditions);
-		$this->assertCount(sizeOf($conditions), $json->conditions);
-		foreach ($conditions as $i => $condition) {
-			foreach ($condition as $key => $val) {
-				$this->assertEquals($val, $json->conditions[$i]->$key);
-			}
-		}
-		
-		return $data;
-	}
-	
-	
-	/**
-	 * @depends testNewSearch
-	 */
-	public function testModifySearch($newSearchData) {
-		$key = $newSearchData['key'];
-		$version = $newSearchData['version'];
-		$json = json_decode($newSearchData['content'], true);
-		
-		// Remove one search condition
-		array_shift($json['conditions']);
-		
-		$name = $json['name'];
-		$conditions = $json['conditions'];
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"searches/$key?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		
-		$xml = API::getSearchXML($key);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals($name, (string) $json->name);
-		$this->assertInternalType('array', $json->conditions);
-		$this->assertCount(sizeOf($conditions), $json->conditions);
-		foreach ($conditions as $i => $condition) {
-			foreach ($condition as $key => $val) {
-				$this->assertEquals($val, $json->conditions[$i]->$key);
-			}
-		}
-	}
-	
-	
-	public function testNewSearchNoName() {
-		$json = API::createSearch(
-			"",
-			array(
-				array(
-					"condition" => "title",
-					"operator" => "contains",
-					"value" => "test"
-				)
-			),
-			$this,
-			'responsejson'
-		);
-		$this->assert400ForObject($json, "Search name cannot be empty");
-	}
-	
-	
-	public function testNewSearchNoConditions() {
-		$json = API::createSearch("Test", array(), $this, 'responsejson');
-		$this->assert400ForObject($json, "'conditions' cannot be empty");
-	}
-	
-	
-	public function testNewSearchConditionErrors() {
-		$json = API::createSearch(
-			"Test",
-			array(
-				array(
-					"operator" => "contains",
-					"value" => "test"
-				)
-			),
-			$this,
-			'responsejson'
-		);
-		$this->assert400ForObject($json, "'condition' property not provided for search condition");
-		
-		$json = API::createSearch(
-			"Test",
-			array(
-				array(
-					"condition" => "",
-					"operator" => "contains",
-					"value" => "test"
-				)
-			),
-			$this,
-			'responsejson'
-		);
-		$this->assert400ForObject($json, "Search condition cannot be empty");
-		
-		$json = API::createSearch(
-			"Test",
-			array(
-				array(
-					"condition" => "title",
-					"value" => "test"
-				)
-			),
-			$this,
-			'responsejson'
-		);
-		$this->assert400ForObject($json, "'operator' property not provided for search condition");
-		
-		$json = API::createSearch(
-			"Test",
-			array(
-				array(
-					"condition" => "title",
-					"operator" => "",
-					"value" => "test"
-				)
-			),
-			$this,
-			'responsejson'
-		);
-		$this->assert400ForObject($json, "Search operator cannot be empty");
-	}
-}
diff --git a/tests/remote/tests/API/2/SettingsTest.php b/tests/remote/tests/API/2/SettingsTest.php
deleted file mode 100644
index b1b56b28..00000000
--- a/tests/remote/tests/API/2/SettingsTest.php
+++ /dev/null
@@ -1,458 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class SettingsTests extends APITests {
-	public function setUp() {
-		parent::setUp();
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-	}
-	
-	
-	public function tearDown() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-	}
-	
-	
-	public function testAddUserSetting() {
-		$settingKey = "tagColors";
-		$value = array(
-			array(
-				"name" => "_READ",
-				"color" => "#990000"
-			)
-		);
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$json = array(
-			"value" => $value
-		);
-		
-		// No version
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert428($response);
-		
-		// Version must be 0 for non-existent setting
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: 1"
-			)
-		);
-		$this->assert412($response);
-		
-		// Create
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: 0"
-			)
-		);
-		$this->assert204($response);
-		
-		// Multi-object GET
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertArrayHasKey($settingKey, $json);
-		$this->assertEquals($value, $json[$settingKey]['value']);
-		$this->assertEquals($libraryVersion + 1, $json[$settingKey]['version']);
-		
-		// Single-object GET
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($value, $json['value']);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-	}
-	
-	
-	public function testAddUserSettingMultiple() {
-		$settingKey = "tagColors";
-		$value = array(
-			array(
-				"name" => "_READ",
-				"color" => "#990000"
-			)
-		);
-		
-		// TODO: multiple, once more settings are supported
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$json = array(
-			$settingKey => array(
-				"value" => $value
-			)
-		);
-		$response = API::userPost(
-			self::$config['userID'],
-			"settings?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		
-		// Multi-object GET
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertArrayHasKey($settingKey, $json);
-		$this->assertEquals($value, $json[$settingKey]['value']);
-		$this->assertEquals($libraryVersion + 1, $json[$settingKey]['version']);
-		
-		// Single-object GET
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($value, $json['value']);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-	}
-	
-	
-	public function testAddGroupSettingMultiple() {
-		$settingKey = "tagColors";
-		$value = array(
-			array(
-				"name" => "_READ",
-				"color" => "#990000"
-			)
-		);
-		
-		// TODO: multiple, once more settings are supported
-		
-		$groupID = self::$config['ownedPrivateGroupID'];
-		$libraryVersion = API::getGroupLibraryVersion($groupID);
-		
-		$json = array(
-			$settingKey => array(
-				"value" => $value
-			)
-		);
-		$response = API::groupPost(
-			$groupID,
-			"settings?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		
-		// Multi-object GET
-		$response = API::groupGet(
-			$groupID,
-			"settings?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertArrayHasKey($settingKey, $json);
-		$this->assertEquals($value, $json[$settingKey]['value']);
-		$this->assertEquals($libraryVersion + 1, $json[$settingKey]['version']);
-		
-		// Single-object GET
-		$response = API::groupGet(
-			$groupID,
-			"settings/$settingKey?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($value, $json['value']);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-	}
-	
-	
-	public function testUpdateUserSetting() {
-		$settingKey = "tagColors";
-		$value = array(
-			array(
-				"name" => "_READ",
-				"color" => "#990000"
-			)
-		);
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$json = array(
-			"value" => $value,
-			"version" => 0
-		);
-		
-		// Create
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json"
-			)
-		);
-		$this->assert204($response);
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($value, $json['value']);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-		
-		// Update with no change
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json"
-			)
-		);
-		$this->assert204($response);
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($value, $json['value']);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-		
-		$newValue = array(
-			array(
-				"name" => "_READ",
-				"color" => "#CC9933"
-			)
-		);
-		$json['value'] = $newValue;
-		
-		// Update, no change
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"Content-Type: application/json"
-			)
-		);
-		$this->assert204($response);
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($newValue, $json['value']);
-		$this->assertEquals($libraryVersion + 2, $json['version']);
-	}
-	
-	
-	public function testDeleteUserSetting() {
-		$settingKey = "tagColors";
-		$value = array(
-			array(
-				"name" => "_READ",
-				"color" => "#990000"
-			)
-		);
-		
-		$json = array(
-			"value" => $value,
-			"version" => 0
-		);
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Create
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		
-		// Delete
-		$response = API::userDelete(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey'],
-			array(
-				"If-Unmodified-Since-Version: " . ($libraryVersion + 1)
-			)
-		);
-		$this->assert204($response);
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey']
-		);
-		$this->assert404($response);
-		
-		$this->assertEquals($libraryVersion + 2, API::getLibraryVersion());
-	}
-	
-	
-	public function testDeleteNonexistentSetting() {
-		$response = API::userDelete(
-			self::$config['userID'],
-			"settings/nonexistentSetting?key=" . self::$config['apiKey'],
-			array(
-				"If-Unmodified-Since-Version: 0"
-			)
-		);
-		$this->assert404($response);
-	}
-	
-	
-	public function testUnsupportedSetting() {
-		$settingKey = "unsupportedSetting";
-		$value = true;
-		
-		$json = array(
-			"value" => $value,
-			"version" => 0
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert400($response, "Invalid setting '$settingKey'");
-	}
-	
-	
-	public function testUnsupportedSettingMultiple() {
-		$settingKey = "unsupportedSetting";
-		$json = array(
-			"tagColors" => array(
-				"value" => array(
-					"name" => "_READ",
-					"color" => "#990000"
-				),
-				"version" => 0
-			),
-			$settingKey => array(
-				"value" => false,
-				"version" => 0
-			)
-		);
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert400($response, "Invalid setting '$settingKey'");
-		
-		// Valid setting shouldn't exist, and library version should be unchanged
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey']
-		);
-		$this->assert404($response);
-		$this->assertEquals($libraryVersion, API::getLibraryVersion());
-	}
-	
-	
-	public function testOverlongSetting() {
-		$settingKey = "tagColors";
-		$value = array(
-			array(
-				"name" => $this->content = str_repeat("abcdefghij", 2001),
-				"color" => "#990000"
-			)
-		);
-		
-		$json = array(
-			"value" => $value,
-			"version" => 0
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert400($response, "'value' cannot be longer than 20000 characters");
-	}
-}
diff --git a/tests/remote/tests/API/2/SortTest.php b/tests/remote/tests/API/2/SortTest.php
deleted file mode 100644
index d7acb67e..00000000
--- a/tests/remote/tests/API/2/SortTest.php
+++ /dev/null
@@ -1,151 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class SortTests extends APITests {
-	private static $collectionKeys = [];
-	private static $itemKeys = [];
-	private static $childAttachmentKeys = [];
-	private static $childNoteKeys = [];
-	private static $searchKeys = [];
-	
-	private static $titles = ['q', 'c', 'a', 'j', 'e', 'h', 'i'];
-	private static $names = ['m', 's', 'a', 'bb', 'ba', '', ''];
-	private static $attachmentTitles = ['v', 'x', null, 'a', null];
-	private static $notes = [null, 'aaa', null, null, 'taf'];
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-		
-		//
-		// Collections
-		//
-		/*for ($i=0; $i<5; $i++) {
-			self::$collectionKeys[] = API::createCollection("Test", false, null, 'key');
-		}*/
-		
-		//
-		// Items
-		//
-		$titles = self::$titles;
-		$names = self::$names;
-		for ($i = 0; $i < sizeOf(self::$titles) - 2; $i++) {
-			$key = API::createItem("book", [
-				"title" => array_shift($titles),
-				"creators" => [
-					[
-						"creatorType" => "author",
-						"name" => array_shift($names)
-					]
-				]
-			], null, 'key');
-			
-			// Child attachments
-			if (!is_null(self::$attachmentTitles[$i])) {
-				self::$childAttachmentKeys[] = API::createAttachmentItem("imported_file", [
-					"title" => self::$attachmentTitles[$i]
-				], $key, null, 'key');
-			}
-			// Child notes
-			if (!is_null(self::$notes[$i])) {
-				self::$childNoteKeys[] = API::createNoteItem(self::$notes[$i], $key, null, 'key');
-			}
-			
-			self::$itemKeys[] = $key;
-		}
-		// Top-level attachment
-		self::$itemKeys[] = API::createAttachmentItem("imported_file", [
-			"title" => array_shift($titles)
-		], false, null, 'key');
-		// Top-level note
-		self::$itemKeys[] = API::createNoteItem(array_shift($titles), false, null, 'key');
-		
-		//
-		// Searches
-		//
-		/*for ($i=0; $i<5; $i++) {
-			self::$searchKeys[] = API::createSearch("Test", 'default', null, 'key');
-		}*/
-	}
-	
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testSortTopItemsTitle() {
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&format=keys&order=title"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$titles = self::$titles;
-		asort($titles);
-		$this->assertCount(sizeOf($titles), $keys);
-		$correct = [];
-		foreach ($titles as $k => $v) {
-			// The key at position k in itemKeys should be at the same position in keys
-			$correct[] = self::$itemKeys[$k];
-		}
-		$this->assertEquals($correct, $keys);
-	}
-	
-	
-	public function testSortTopItemsCreator() {
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?key=" . self::$config['apiKey'] . "&format=keys&order=creator"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$names = self::$names;
-		uasort($names, function ($a, $b) {
-			if ($a === '' && $b !== '') return 1;
-			if ($b === '' && $a !== '') return -1;
-			return strcmp($a, $b);
-		});
-		$this->assertCount(sizeOf($names), $keys);
-		$endKeys = array_splice($keys, -2);
-		$correct = [];
-		foreach ($names as $k => $v) {
-			// The key at position k in itemKeys should be at the same position in keys
-			$correct[] = self::$itemKeys[$k];
-		}
-		// Remove empty names
-		array_splice($correct, -2);
-		$this->assertEquals($correct, $keys);
-		// Check attachment and note, which should fall back to ordered added (itemID)
-		$this->assertEquals(array_slice(self::$itemKeys, -2), $endKeys);
-	}
-}
diff --git a/tests/remote/tests/API/2/StorageAdminTest.php b/tests/remote/tests/API/2/StorageAdminTest.php
deleted file mode 100644
index 33288d76..00000000
--- a/tests/remote/tests/API/2/StorageAdminTest.php
+++ /dev/null
@@ -1,113 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/bootstrap.inc.php';
-
-class StorageAdminTests extends APITests {
-	const DEFAULT_QUOTA = 300;
-	
-	private static $toDelete = array();
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-	}
-	
-	public function setUp() {
-		parent::setUp();
-		
-		// Clear subscription
-		$response = API::post(
-			'users/' . self::$config['userID'] . '/storageadmin',
-			'quota=0&expiration=0',
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(self::DEFAULT_QUOTA, (int) $xml->quota);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		
-		// Clear subscription
-		$response = API::post(
-			'users/' . self::$config['userID'] . '/storageadmin',
-			'quota=0&expiration=0',
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-	}
-	
-	
-	public function test2GB() {
-		$quota = 2000;
-		$expiration = time() + (86400 * 365);
-		
-		$response = API::post(
-			'users/' . self::$config['userID'] . '/storageadmin',
-			"quota=$quota&expiration=$expiration",
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals($quota, (int) $xml->quota);
-		$this->assertEquals($expiration, (int) $xml->expiration);
-	}
-	
-	
-	public function testUnlimited() {
-		$quota = 'unlimited';
-		$expiration = time() + (86400 * 365);
-		
-		$response = API::post(
-			'users/' . self::$config['userID'] . '/storageadmin',
-			"quota=$quota&expiration=$expiration",
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals($quota, (string) $xml->quota);
-		$this->assertEquals($expiration, (int) $xml->expiration);
-	}
-}
diff --git a/tests/remote/tests/API/2/TagTest.php b/tests/remote/tests/API/2/TagTest.php
deleted file mode 100644
index 7fdecc61..00000000
--- a/tests/remote/tests/API/2/TagTest.php
+++ /dev/null
@@ -1,385 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class TagTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	
-	public function setUp() {
-		parent::setUp();
-		API::useAPIVersion(2);
-	}
-	
-	public function testEmptyTag() {
-		$json = API::getItemTemplate("book");
-		$json->tags[] = array(
-			"tag" => "",
-			"type" => 1
-		);
-		
-		$response = API::postItem($json);
-		$this->assert400ForObject($response, "Tag cannot be empty");
-	}
-	
-	
-	public function testInvalidTagObject() {
-		$json = API::getItemTemplate("book");
-		$json->tags[] = array("invalid");
-		
-		$response = API::postItem($json);
-		$this->assert400ForObject($response, "Tag must be an object");
-	}
-	
-	
-	public function testItemTagSearch() {
-		API::userClear(self::$config['userID']);
-		
-		// Create items with tags
-		$key1 = API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "b")
-			)
-		), $this, 'key');
-		
-		$key2 = API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "c")
-			)
-		), $this, 'key');
-		
-		//
-		// Searches
-		//
-		
-		// a (both)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&"
-				. "tag=a"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// a and c (#2)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&"
-				. "tag=a&tag=c"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// b and c (none)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&"
-				. "tag=b&tag=c"
-		);
-		$this->assert200($response);
-		$this->assertEmpty(trim($response->getBody()));
-		
-		// b or c (both)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&"
-				. "tag=b%20||%20c"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// a or b or c (both)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&"
-				. "tag=a%20||%20b%20||%20c"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// not a (none)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&"
-				. "tag=-a"
-		);
-		$this->assert200($response);
-		$this->assertEmpty(trim($response->getBody()));
-		
-		// not b (#2)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&"
-				. "tag=-b"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// (b or c) and a (both)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&"
-				. "tag=b%20||%20c&tag=a"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// not nonexistent (both)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&"
-				. "tag=-z"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// A (case-insensitive search)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&"
-				. "tag=B"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(1, $keys);
-		$this->assertContains($key1, $keys);
-	}
-	
-	
-	public function testTagSearch() {
-		$tags1 = array("a", "aa", "b");
-		$tags2 = array("b", "c", "cc");
-		
-		$itemKey1 = API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags1)
-		), $this, 'key');
-		
-		$itemKey2 = API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags2)
-		), $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey']
-				. "&content=json&tag=" . implode("%20||%20", $tags1)
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($tags1), $response);
-	}
-	
-	
-	public function testTagNewer() {
-		API::userClear(self::$config['userID']);
-		
-		// Create items with tags
-		API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "b")
-			)
-		), $this);
-		
-		$version = API::getLibraryVersion();
-		
-		// 'newer' shouldn't return any results
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey'] . "&content=json&newer=$version"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-		
-		// Create another item with tags
-		API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "c")
-			)
-		), $this);
-		
-		// 'newer' should return new tag
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey'] . "&content=json&newer=$version"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$this->assertGreaterThan($version, $response->getHeader('Last-Modified-Version'));
-		$content = API::getContentFromResponse($response);
-		$json = json_decode($content, true);
-		$this->assertEquals("c", $json['tag']);
-		$this->assertEquals(0, $json['type']);
-	}
-	
-	
-	public function testMultiTagDelete() {
-		$tags1 = array("a", "aa", "b");
-		$tags2 = array("b", "c", "cc");
-		$tags3 = array("Foo");
-		
-		API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags1)
-		), $this, 'key');
-		
-		API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag, "type" => 1);
-			}, $tags2)
-		), $this, 'key');
-		
-		API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags3)
-		), $this, 'key');
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Missing version header
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey']
-				. "&content=json&tag="
-				. implode("%20||%20", array_merge($tags1, $tags2))
-		);
-		$this->assert428($response);
-		
-		// Outdated version header
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey']
-				. "&content=json&tag="
-				. implode("%20||%20", array_merge($tags1, $tags2)),
-			array("If-Unmodified-Since-Version: " . ($libraryVersion - 1))
-		);
-		$this->assert412($response);
-		
-		// Delete
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey']
-				. "&content=json&tag="
-				. implode("%20||%20", array_merge($tags1, $tags2)),
-			array("If-Unmodified-Since-Version: $libraryVersion")
-		);
-		$this->assert204($response);
-		
-		// Make sure they're gone
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey']
-				. "&content=json&tag="
-				. implode("%20||%20", array_merge($tags1, $tags2, $tags3))
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-	}
-	
-	
-	/**
-	 * When modifying a tag on an item, only the item itself should have its
-	 * version updated, not other items that had (and still have) the same tag
-	 */
-	public function testTagAddItemVersionChange() {
-		$data1 = API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "b")
-			)
-		), $this, 'data');
-		$json1 = json_decode($data1['content'], true);
-		$version1 = $data1['version'];
-		
-		$data2 = API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "c")
-			)
-		), $this, 'data');
-		$json2 = json_decode($data2['content'], true);
-		$version2 = $data2['version'];
-		
-		// Remove tag 'a' from item 1
-		$json1['tags'] = array(
-			array("tag" => "d"),
-			array("tag" => "c")
-		);
-		
-		$response = API::postItem($json1);
-		$this->assert200($response);
-		
-		// Item 1 version should be one greater than last update
-		$xml1 = API::getItemXML($json1['itemKey']);
-		$data1 = API::parseDataFromAtomEntry($xml1);
-		$this->assertEquals($version2 + 1, $data1['version']);
-		
-		// Item 2 version shouldn't have changed
-		$xml2 = API::getItemXML($json2['itemKey']);
-		$data2 = API::parseDataFromAtomEntry($xml2);
-		$this->assertEquals($version2, $data2['version']);
-	}
-}
diff --git a/tests/remote/tests/API/2/VersionTest.php b/tests/remote/tests/API/2/VersionTest.php
deleted file mode 100644
index a055ed7c..00000000
--- a/tests/remote/tests/API/2/VersionTest.php
+++ /dev/null
@@ -1,630 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv2;
-use API2 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api2.inc.php';
-
-class VersionTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public function setUp() {
-		parent::setUp();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testSingleObjectLastModifiedVersion() {
-		$this->_testSingleObjectLastModifiedVersion('collection');
-		$this->_testSingleObjectLastModifiedVersion('item');
-		$this->_testSingleObjectLastModifiedVersion('search');
-	}
-	
-	
-	public function testMultiObjectLastModifiedVersion() {
-		$this->_testMultiObjectLastModifiedVersion('collection');
-		$this->_testMultiObjectLastModifiedVersion('item');
-		$this->_testMultiObjectLastModifiedVersion('search');
-	}
-	
-	
-	public function testMultiObject304NotModified() {
-		$this->_testMultiObject304NotModified('collection');
-		$this->_testMultiObject304NotModified('item');
-		$this->_testMultiObject304NotModified('search');
-		$this->_testMultiObject304NotModified('tag');
-	}
-	
-	
-	public function testNewerAndVersionsFormat() {
-		$this->_testNewerAndVersionsFormat('collection');
-		$this->_testNewerAndVersionsFormat('item');
-		$this->_testNewerAndVersionsFormat('search');
-	}
-	
-	
-	public function testUploadUnmodified() {
-		$this->_testUploadUnmodified('collection');
-		$this->_testUploadUnmodified('item');
-		$this->_testUploadUnmodified('search');
-	}
-	
-	
-	public function testNewerTags() {
-		$tags1 = array("a", "aa", "b");
-		$tags2 = array("b", "c", "cc");
-		
-		$data1 = API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags1)
-		), $this, 'data');
-		
-		$data2 = API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags2)
-		), $this, 'data');
-		
-		// Only newly added tags should be included in newer,
-		// not previously added tags or tags added to items
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey']
-				. "&newer=" . $data1['version']
-		);
-		$this->assertNumResults(2, $response);
-		
-		// Deleting an item shouldn't update associated tag versions
-		$response = API::userDelete(
-			self::$config['userID'],
-			"items/{$data1['key']}?key=" . self::$config['apiKey'],
-			array(
-				"If-Unmodified-Since-Version: " . $data1['version']
-			)
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey']
-				. "&newer=" . $data1['version']
-		);
-		$this->assertNumResults(2, $response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey']
-				. "&newer=" . $libraryVersion
-		);
-		$this->assertNumResults(0, $response);
-	}
-	
-	
-	private function _testSingleObjectLastModifiedVersion($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$keyProp = $objectType . "Key";
-		$versionProp = $objectType . "Version";
-		
-		switch ($objectType) {
-		case 'collection':
-			$objectKey = API::createCollection("Name", false, $this, 'key');
-			break;
-		
-		case 'item':
-			$objectKey = API::createItem("book", array("title" => "Title"), $this, 'key');
-			break;
-		
-		case 'search':
-			$objectKey = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this,
-				'key'
-			);
-			break;
-		}
-		
-		// Make sure all three instances of the object version
-		// (Last-Modified-Version, zapi:version, and the JSON
-		// {$objectType}Version property match the library version
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$this->assert200($response);
-		$objectVersion = $response->getHeader("Last-Modified-Version");
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$this->assertEquals($objectVersion, $json->$versionProp);
-		$this->assertEquals($objectVersion, $data['version']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'] . "&limit=1"
-		);
-		$this->assert200($response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		
-		$this->assertEquals($libraryVersion, $objectVersion);
-		
-		$this->_modifyJSONObject($objectType, $json);
-		
-		// No If-Unmodified-Since-Version or JSON version property
-		unset($json->$versionProp);
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert428($response);
-		
-		// Out of date version
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"If-Unmodified-Since-Version: " . ($objectVersion - 1)
-			)
-		);
-		$this->assert412($response);
-		
-		// Update with version header
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array(
-				"If-Unmodified-Since-Version: " . $objectVersion
-			)
-		);
-		$this->assert204($response);
-		$newObjectVersion = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($objectVersion, $newObjectVersion);
-		
-		// Update object with JSON version property
-		$this->_modifyJSONObject($objectType, $json);
-		$json->$versionProp = $newObjectVersion;
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		$newObjectVersion2 = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($newObjectVersion, $newObjectVersion2);
-		
-		// Make sure new library version matches new object version
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'] . "&limit=1"
-		);
-		$this->assert200($response);
-		$newLibraryVersion = $response->getHeader("Last-Modified-Version");
-		$this->assertEquals($newObjectVersion2, $newLibraryVersion);
-		
-		// Create an item to increase the library version, and make sure
-		// original object version stays the same
-		API::createItem("book", array("title" => "Title"), $this, 'key');
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'] . "&limit=1"
-		);
-		$this->assert200($response);
-		$newObjectVersion2 = $response->getHeader("Last-Modified-Version");
-		$this->assertEquals($newLibraryVersion, $newObjectVersion2);
-		
-		//
-		// Delete object
-		//
-		
-		// No If-Unmodified-Since-Version
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey']
-		);
-		$this->assert428($response);
-		
-		// Outdated If-Unmodified-Since-Version
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'],
-			array(
-				"If-Unmodified-Since-Version: " . $objectVersion
-			)
-		);
-		$this->assert412($response);
-		
-		// Delete object
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'],
-			array(
-				"If-Unmodified-Since-Version: " . $newObjectVersion2
-			)
-		);
-		$this->assert204($response);
-	}
-	
-	
-	private function _modifyJSONObject($objectType, $json) {
-		// Modifying object should increase its version
-		switch ($objectType) {
-		case 'collection':
-			$json->name = "New Name " . uniqid();
-			break;
-		
-		case 'item':
-			$json->title = "New Title" . uniqid();
-			break;
-		
-		case 'search':
-			$json->name = "New Name" . uniqid();
-			break;
-		}
-	}
-	
-	
-	private function _testMultiObjectLastModifiedVersion($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$objectKeyProp = $objectType . "Key";
-		$objectVersionProp = $objectType . "Version";
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'] . "&limit=1"
-		);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version));
-		
-		switch ($objectType) {
-		case 'collection':
-			$json = new \stdClass();
-			$json->name = "Name";
-			break;
-		
-		case 'item':
-			$json = API::getItemTemplate("book");
-			break;
-		
-		case 'search':
-			$json = new \stdClass();
-			$json->name = "Name";
-			$json->conditions = array(
-				array(
-					"condition" => "title",
-					"operator" => "contains",
-					"value" => "test"
-				)
-			);
-			break;
-		}
-		
-		// Outdated library version
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'],
-			json_encode(array(
-				$objectTypePlural => array($json)
-			)),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: " . ($version - 1)
-			)
-		);
-		$this->assert412($response);
-		
-		// Make sure version didn't change during failure
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'] . "&limit=1"
-		);
-		$this->assertEquals($version, $response->getHeader("Last-Modified-Version"));
-		
-		// Create a new object, using library timestamp
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'],
-			json_encode(array(
-				$objectTypePlural => array($json)
-			)),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert200($response);
-		$version2 = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version2));
-		// Version should be incremented on new object
-		$this->assertGreaterThan($version, $version2);
-		$objectKey = API::getFirstSuccessKeyFromResponse($response);
-		
-		// Check single-object request
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$this->assert200($response);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version));
-		$this->assertEquals($version, $version2);
-		$json = json_decode(API::getContentFromResponse($response));
-		
-		// Modify object
-		$json->$objectKeyProp = $objectKey;
-		switch ($objectType) {
-		case 'collection':
-			$json->name = "New Name";
-			break;
-		
-		case 'item':
-			$json->title = "New Title";
-			break;
-		
-		case 'search':
-			$json->name = "New Name";
-			break;
-		}
-		
-		// No If-Unmodified-Since-Version or object version property
-		unset($json->$objectVersionProp);
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'],
-			json_encode(array(
-				$objectTypePlural => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert428ForObject($response);
-		
-		// Outdated object version property
-		$json->$objectVersionProp = $version - 1;
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'],
-			json_encode(array(
-				$objectTypePlural => array($json)
-			)),
-			array(
-				"Content-Type: application/json"
-			)
-		);
-		$this->assert412ForObject($response, ucwords($objectType)
-			. " has been modified since specified version "
-			. "(expected {$json->$objectVersionProp}, found $version)");
-		
-		// Modify object, using object version property
-		$json->$objectVersionProp = $version;
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'],
-			json_encode(array(
-				$objectTypePlural => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		// Version should be incremented on modified object
-		$version3 = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version3));
-		$this->assertGreaterThan($version2, $version3);
-		
-		// Check library version
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-		);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version));
-		$this->assertEquals($version, $version3);
-		
-		// Check single-object request
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?key=" . self::$config['apiKey']
-		);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version));
-		$this->assertEquals($version, $version3);
-		
-		// TODO: Version should be incremented on deleted item
-	}
-	
-	
-	private function _testMultiObject304NotModified($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-		);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version));
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey'],
-			array(
-				"If-Modified-Since-Version: $version"
-			)
-		);
-		$this->assert304($response);
-	}
-	
-	
-	private function _testNewerAndVersionsFormat($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$xmlArray = array();
-		
-		switch ($objectType) {
-		case 'collection':
-			$xmlArray[] = API::createCollection("Name", false, $this);
-			$xmlArray[] = API::createCollection("Name", false, $this);
-			$xmlArray[] = API::createCollection("Name", false, $this);
-			break;
-		
-		case 'item':
-			$xmlArray[] = API::createItem("book", array("title" => "Title"), $this);
-			$xmlArray[] = API::createItem("book", array("title" => "Title"), $this);
-			$xmlArray[] = API::createItem("book", array("title" => "Title"), $this);
-			break;
-		
-		
-		case 'search':
-			$xmlArray[] = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this
-			);
-			$xmlArray[] = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this
-			);
-			$xmlArray[] = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this
-			);
-		}
-		
-		$objects = array();
-		while ($xml = array_shift($xmlArray)) {
-			$data = API::parseDataFromAtomEntry($xml);
-			$objects[] = array(
-				"key" => $data['key'],
-				"version" => $data['version']
-			);
-		}
-		
-		$firstVersion = $objects[0]['version'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&format=versions&newer=$firstVersion"
-		);
-		
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		
-		$keys = array_keys($json);
-		
-		$this->assertEquals($objects[2]['key'], array_shift($keys));
-		$this->assertEquals($objects[2]['version'], array_shift($json));
-		$this->assertEquals($objects[1]['key'], array_shift($keys));
-		$this->assertEquals($objects[1]['version'], array_shift($json));
-		$this->assertEmpty($json);
-	}
-	
-	
-	private function _testUploadUnmodified($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'collection':
-			$xml = API::createCollection("Name", false, $this);
-			break;
-		
-		case 'item':
-			$xml = API::createItem("book", array("title" => "Title"), $this);
-			break;
-		
-		case 'search':
-			$xml = API::createSearch("Name", 'default', $this);
-			break;
-		}
-		
-		$version = (int) array_get_first($xml->xpath('//atom:entry/zapi:version'));
-		$this->assertNotEquals(0, $version);
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/{$data['key']}?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assert204($response);
-		$this->assertEquals($version, $response->getHeader("Last-Modified-Version"));
-		
-		switch ($objectType) {
-		case 'collection':
-			$xml = API::getCollectionXML($data['key']);
-			break;
-		
-		case 'item':
-			$xml = API::getItemXML($data['key']);
-			break;
-		
-		case 'search':
-			$xml = API::getSearchXML($data['key']);
-			break;
-		}
-		$data = API::parseDataFromAtomEntry($xml);
-		$this->assertEquals($version, $data['version']);
-	}
-}
diff --git a/tests/remote/tests/API/3/APITests.inc.php b/tests/remote/tests/API/3/APITests.inc.php
deleted file mode 100644
index 45e5122b..00000000
--- a/tests/remote/tests/API/3/APITests.inc.php
+++ /dev/null
@@ -1,252 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-require_once 'tests/API/APITests.inc.php';
-use API3 as API, \Exception, \SimpleXMLElement;
-require_once 'include/api3.inc.php';
-
-//
-// Helper functions
-//
-class APITests extends \APITests {
-	protected static $config;
-	protected static $nsZAPI;
-	private $notificationHeader = 'zotero-debug-notifications';
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		self::$nsZAPI = 'http://zotero.org/ns/api';
-		
-		API::useAPIVersion(3);
-		
-		API::setKeyUserPermission(self::$config['apiKey'], 'notes', true);
-		API::setKeyUserPermission(self::$config['apiKey'], 'write', true);
-	}
-	
-	
-	public function setUp() {
-		parent::setUp();
-		API::useAPIKey(self::$config['apiKey']);
-		API::useAPIVersion(3);
-		$this->apiVersion = 3;
-	}
-	
-	
-	public function test() {}
-	
-	public function __call($name, $arguments) {
-		if (preg_match("/^assert([1-5][0-9]{2})$/", $name, $matches)) {
-			$this->assertHTTPStatus($matches[1], $arguments[0]);
-			// Check response body
-			if (isset($arguments[1])) {
-				$this->assertEquals($arguments[1], $arguments[0]->getBody());
-			}
-			return;
-		}
-		// assertNNNForObject($response, $message=false, $pos=0)
-		if (preg_match("/^assert([1-5][0-9]{2}|Unchanged)ForObject$/", $name, $matches)) {
-			$code = $matches[1];
-			if ($arguments[0] instanceof \HTTP_Request2_Response) {
-				$this->assert200($arguments[0]);
-				$json = json_decode($arguments[0]->getBody(), true);
-			}
-			else if (is_string($arguments[0])) {
-				$json = json_decode($arguments[0], true);
-			}
-			else {
-				$json = $arguments[0];
-			}
-			$this->assertNotNull($json);
-			
-			if ($code == 'Unchanged') {
-				$index = isset($arguments[1]) ? $arguments[1] : 0;
-			}
-			else {
-				$expectedMessage = !empty($arguments[1]) ? $arguments[1] : false;
-				$index = isset($arguments[2]) ? $arguments[2] : 0;
-			}
-			
-			if ($code == 200) {
-				$this->assertArrayHasKey('successful', $json);
-				if (!isset($json['successful'][$index])) {
-					var_dump($json);
-					throw new Exception("Index $index not found in 'successful' object");
-				}
-				// Deprecated
-				$this->assertArrayHasKey('success', $json);
-				if (!isset($json['success'][$index])) {
-					var_dump($json);
-					throw new Exception("Index $index not found in success object");
-				}
-				if ($expectedMessage) {
-					throw new Exception("Cannot check response message of object for HTTP $code");
-				}
-			}
-			else if ($code == 'Unchanged') {
-				try {
-					$this->assertArrayHasKey('unchanged', $json);
-					$this->assertArrayHasKey($index, $json['unchanged']);
-				}
-				catch (Exception $e) {
-					var_dump($json);
-					throw $e;
-				}
-			}
-			else if ($code[0] == '4' || $code[0] == '5') {
-				try {
-					$this->assertArrayHasKey('failed', $json);
-					$this->assertArrayHasKey($index, $json['failed']);
-				}
-				catch (Exception $e) {
-					var_dump($json);
-					throw $e;
-				}
-				try {
-					$this->assertEquals($code, $json['failed'][$index]['code']);
-				}
-				catch (Exception $e) {
-					var_dump($json);
-					throw $e;
-				}
-				if ($expectedMessage) {
-					$this->assertEquals($expectedMessage, $json['failed'][$index]['message']);
-				}
-			}
-			else {
-				throw new Exception("HTTP $code cannot be returned for an individual object");
-			}
-			return;
-		}
-		throw new Exception("Invalid function $name");
-	}
-	
-	
-	protected function assertHasResults($res) {
-		$xml = $res->getBody();
-		$xml = new SimpleXMLElement($xml);
-		
-		$zapiNodes = $xml->children(self::$nsZAPI);
-		$this->assertNotEquals(0, (int) $zapiNodes->totalResults);
-		$this->assertNotEquals(0, count($xml->entry));
-	}
-	
-	
-	protected function assertTotalResults($num, $response) {
-		$this->assertTrue(is_numeric($response->getHeader('Total-Results')));
-		$this->assertEquals($num, (int) $response->getHeader('Total-Results'));
-	}
-	
-	
-	protected function assertNumResults($num, $response) {
-		$contentType = $response->getHeader('Content-Type');
-		if ($contentType == 'application/json') {
-			$json = API::getJSONFromResponse($response);
-			$this->assertEquals($num, count($json));
-		}
-		else if (strpos($contentType, 'text/plain') === 0) {
-			$rows = array_filter(explode("\n", trim($response->getBody())));
-			$this->assertEquals($num, count($rows));
-		}
-		else if ($contentType == 'application/atom+xml') {
-			$xml = $response->getBody();
-			$xml = new SimpleXMLElement($xml);
-			$this->assertEquals($num, count($xml->entry));
-		}
-		else if ($contentType == 'application/x-bibtex') {
-			$matched = preg_match_all('/^@[a-z]+{/m', $response->getBody());
-			$this->assertEquals($num, $matched);
-		}
-		else {
-			throw new Exception("Unknown content type '$contentType'");
-		}
-	}
-	
-	
-	protected function assertNoResults($response) {
-		$this->assertTotalResults(0, $response);
-		
-		$contentType = $response->getHeader('Content-Type');
-		if ($contentType == 'application/json') {
-			$json = API::getJSONFromResponse($response);
-			$this->assertEquals(0, count($json));
-		}
-		else if ($contentType == 'application/atom+xml') {
-			$xml = new SimpleXMLElement($response->getBody());
-			$zapiNodes = $xml->children(self::$nsZAPI);
-			$this->assertEquals(0, count($xml->entry));
-		}
-		else {
-			throw new Exception("Unknown content type '$contentType'");
-		}
-	}
-	
-	
-	protected function assertLastModifiedVersion($expected, $response) {
-		$this->assertSame(
-			is_numeric($expected) ? (string) $expected : $expected,
-			$response->getHeader('Last-Modified-Version')
-		);
-	}
-	
-	
-	protected function assertCountNotifications($expected, $response) {
-		$header = $response->getHeader($this->notificationHeader);
-		try {
-			if ($expected === 0) {
-				$this->assertNull($header);
-			}
-			else {
-				$this->assertNotNull($header);
-				$this->assertCount($expected, json_decode(base64_decode($header), true));
-			}
-		}
-		catch (Exception $e) {
-			echo "\nHeaders: " . base64_decode($header) . "\n";
-			throw $e;
-		}
-	}
-	
-	
-	protected function assertHasNotification($notification, $response) {
-		$header = $response->getHeader($this->notificationHeader);
-		$this->assertNotNull($header);
-		// Header contains a Base64-encode array of encoded JSON notifications
-		$notifications = json_decode(base64_decode($header), true);
-		try {
-			$this->assertContains($notification, array_map(function ($x) {
-				return json_decode($x, true);
-			}, $notifications));
-		}
-		catch (Exception $e) {
-			echo "\nHeaders: " . base64_decode($header) . "\n";
-			throw $e;
-		}
-	}
-}
diff --git a/tests/remote/tests/API/3/AtomTest.php b/tests/remote/tests/API/3/AtomTest.php
deleted file mode 100644
index bb635503..00000000
--- a/tests/remote/tests/API/3/AtomTest.php
+++ /dev/null
@@ -1,189 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class AtomTests extends APITests {
-	private static $items;
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-		
-		// Create test data
-		$key = API::createItem("book", array(
-			"title" => "Title",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				)
-			)
-		), null, 'key');
-		self::$items[$key] = '<content xmlns:zapi="http://zotero.org/ns/api" type="application/xml"><zapi:subcontent zapi:type="bib"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 1.35; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, First. <i>Title</i>, n.d.</div></div></zapi:subcontent><zapi:subcontent zapi:type="json">'
-			. \Zotero_Utilities::formatJSON(json_decode('{"key":"","version":0,"itemType":"book","title":"Title","creators":[{"creatorType":"author","firstName":"First","lastName":"Last"}],"abstractNote":"","series":"","seriesNumber":"","volume":"","numberOfVolumes":"","edition":"","place":"","publisher":"","date":"","numPages":"","language":"","ISBN":"","shortTitle":"","url":"","accessDate":"","archive":"","archiveLocation":"","libraryCatalog":"","callNumber":"","rights":"","extra":"","tags":[],"collections":[],"relations":{},"dateAdded":"","dateModified":""}'))
-			. '</zapi:subcontent></content>';
-		
-		$key = API::createItem("book", array(
-			"title" => "Title 2",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				),
-				array(
-					"creatorType" => "editor",
-					"firstName" => "Ed",
-					"lastName" => "McEditor"
-				)
-			)
-		), null, 'key');
-		self::$items[$key] = '<content xmlns:zapi="http://zotero.org/ns/api" type="application/xml"><zapi:subcontent zapi:type="bib"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 1.35; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, First. <i>Title 2</i>. Edited by Ed McEditor, n.d.</div></div></zapi:subcontent><zapi:subcontent zapi:type="json">'
-			. \Zotero_Utilities::formatJSON(json_decode('{"key":"","version":0,"itemType":"book","title":"Title 2","creators":[{"creatorType":"author","firstName":"First","lastName":"Last"},{"creatorType":"editor","firstName":"Ed","lastName":"McEditor"}],"abstractNote":"","series":"","seriesNumber":"","volume":"","numberOfVolumes":"","edition":"","place":"","publisher":"","date":"","numPages":"","language":"","ISBN":"","shortTitle":"","url":"","accessDate":"","archive":"","archiveLocation":"","libraryCatalog":"","callNumber":"","rights":"","extra":"","tags":[],"collections":[],"relations":{},"dateAdded":"","dateModified":""}'))
-			. '</zapi:subcontent></content>';
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testFeedURIs() {
-		$userID = self::$config['userID'];
-		
-		$response = API::userGet(
-			$userID,
-			"items?format=atom"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$links = $xml->xpath('/atom:feed/atom:link');
-		$this->assertEquals(self::$config['apiURLPrefix'] . "users/$userID/items?format=atom", (string) $links[0]['href']);
-		
-		// 'order'/'sort' should turn into 'sort'/'direction'
-		$response = API::userGet(
-			$userID,
-			"items?format=atom&order=dateModified&sort=asc"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$links = $xml->xpath('/atom:feed/atom:link');
-		$this->assertEquals(self::$config['apiURLPrefix'] . "users/$userID/items?direction=asc&format=atom&sort=dateModified", (string) $links[0]['href']);
-	}
-	
-	
-	public function testTotalResults() {
-		$response = API::userHead(
-			self::$config['userID'],
-			"items?format=atom"
-		);
-		$this->assert200($response);
-		$this->assertTotalResults(sizeOf(self::$items), $response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=atom"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertTotalResults(sizeOf(self::$items), $response);
-		// Make sure there's no totalResults tag
-		$this->assertCount(0, $xml->xpath('/atom:feed/zapi:totalResults'));
-	}
-	
-	
-	public function testMultiContent() {
-		$keys = array_keys(self::$items);
-		$keyStr = implode(',', $keys);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?itemKey=$keyStr&content=bib,json"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertTotalResults(sizeOf($keys), $response);
-		
-		$entries = $xml->xpath('//atom:entry');
-		foreach ($entries as $entry) {
-			$key = (string) $entry->children("http://zotero.org/ns/api")->key;
-			$content = $entry->content->asXML();
-			
-			// Add namespace prefix (from <entry>)
-			$content = str_replace('<content ', '<content xmlns:zapi="http://zotero.org/ns/api" ', $content);
-			// Strip variable key and version
-			$content = preg_replace(
-				'%"key": "[A-Z0-9]{8}",(\s+)"version": [0-9]+%',
-				'"key": "",$1"version": 0',
-				$content
-			);
-			
-			// Strip dateAdded/dateModified
-			$iso8601Pattern = '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z';
-			$content = preg_replace(
-				'/"dateAdded": "' . $iso8601Pattern . '",(\s+)"dateModified": "' . $iso8601Pattern . '"/',
-				'"dateAdded": "",$1"dateModified": ""',
-				$content
-			);
-			
-			$this->assertXmlStringEqualsXmlString(self::$items[$key], $content);
-		}
-	}
-	
-	
-	public function testMultiContentCached() {
-		self::testMultiContent();
-	}
-	
-	
-	public function testAcceptHeader() {
-		$response = API::userGet(
-			self::$config['userID'],
-			"items",
-			[
-				"Accept: application/atom+xml,application/rdf+xml,application/rss+xml,application/xml,text/xml,*/*"
-			]
-		);
-		$this->assertContentType('application/atom+xml', $response);
-		
-		// But format= should still override
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=json",
-			[
-				"Accept: application/atom+xml,application/rdf+xml,application/rss+xml,application/xml,text/xml,*/*"
-			]
-		);
-		$this->assertContentType('application/json', $response);
-	}
-}
-?>
diff --git a/tests/remote/tests/API/3/BibTest.php b/tests/remote/tests/API/3/BibTest.php
deleted file mode 100644
index 1d938116..00000000
--- a/tests/remote/tests/API/3/BibTest.php
+++ /dev/null
@@ -1,336 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class BibTests extends APITests {
-	private static $items;
-	private static $multiResponses = [];
-	private static $styles = [
-		"default",
-		"apa",
-		"https://www.zotero.org/styles/apa",
-		"https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl"
-	];
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-		
-		// Create test data
-		$key = API::createItem("book", array(
-			"title" => "Title",
-			"date" => "January 1, 2014",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				)
-			)
-		), null, 'key');
-		self::$items[$key] = [
-			'json' => [
-				"citation" => array(
-					"default" => '<span>Last, <i>Title</i>.</span>',
-					"apa" => '<span>(Last, 2014)</span>',
-					"https://www.zotero.org/styles/apa" => '<span>(Last, 2014)</span>',
-					"https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl" => '<span>[1]</span>'
-				),
-				"bib" => array(
-					"default" => '<div class="csl-bib-body" style="line-height: 1.35; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, First. <i>Title</i>, 2014.</div></div>',
-					"apa" => '<div class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (2014). <i>Title</i>.</div></div>',
-					"https://www.zotero.org/styles/apa" => '<div class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (2014). <i>Title</i>.</div></div>',
-					"https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl" => '<div class="csl-bib-body" style="line-height: 1.35; "><div class="csl-entry" style="clear: left; "><div class="csl-left-margin" style="float: left; padding-right: 0.5em; text-align: right; width: 1em;">[1]</div><div class="csl-right-inline" style="margin: 0 .4em 0 1.5em;">F. Last, <i>Title</i>. 2014.</div></div></div>'
-				)
-			],
-			'atom' => [
-				"citation" => array(
-					"default" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">Last, <i>Title</i>.</span></content>',
-					"apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">(Last, 2014)</span></content>',
-					"https://www.zotero.org/styles/apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">(Last, 2014)</span></content>',
-					"https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">[1]</span></content>'
-				),
-				"bib" => array(
-					"default" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 1.35; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, First. <i>Title</i>, 2014.</div></div></content>',
-					"apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (2014). <i>Title</i>.</div></div></content>',
-					"https://www.zotero.org/styles/apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (2014). <i>Title</i>.</div></div></content>',
-					"https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 1.35; "><div class="csl-entry" style="clear: left; "><div class="csl-left-margin" style="float: left; padding-right: 0.5em; text-align: right; width: 1em;">[1]</div><div class="csl-right-inline" style="margin: 0 .4em 0 1.5em;">F. Last, <i>Title</i>. 2014.</div></div></div></content>'
-				)
-			]
-		];
-		
-		$key = API::createItem("book", array(
-			"title" => "Title 2",
-			"date" => "June 24, 2014",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				),
-				array(
-					"creatorType" => "editor",
-					"firstName" => "Ed",
-					"lastName" => "McEditor"
-				)
-			)
-		), null, 'key');
-		self::$items[$key] = [
-			'json' => [
-				"citation" => array(
-					"default" => '<span>Last, <i>Title 2</i>.</span>',
-					"apa" => '<span>(Last, 2014)</span>',
-					"https://www.zotero.org/styles/apa" => '<span>(Last, 2014)</span>',
-					"https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl" => '<span>[1]</span>'
-				),
-				"bib" => array(
-					"default" => '<div class="csl-bib-body" style="line-height: 1.35; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, First. <i>Title 2</i>. Edited by Ed McEditor, 2014.</div></div>',
-					"apa" => '<div class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (2014). <i>Title 2</i>. (E. McEditor, Ed.).</div></div>',
-					"https://www.zotero.org/styles/apa" => '<div class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (2014). <i>Title 2</i>. (E. McEditor, Ed.).</div></div>',
-					"https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl" => '<div class="csl-bib-body" style="line-height: 1.35; "><div class="csl-entry" style="clear: left; "><div class="csl-left-margin" style="float: left; padding-right: 0.5em; text-align: right; width: 1em;">[1]</div><div class="csl-right-inline" style="margin: 0 .4em 0 1.5em;">F. Last, <i>Title 2</i>. 2014.</div></div></div>'
-				)
-			],
-			'atom' => [
-				"citation" => array(
-					"default" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">Last, <i>Title 2</i>.</span></content>',
-					"apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">(Last, 2014)</span></content>',
-					"https://www.zotero.org/styles/apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">(Last, 2014)</span></content>',
-					"https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="citation" type="xhtml"><span xmlns="http://www.w3.org/1999/xhtml">[1]</span></content>'
-				),
-				"bib" => array(
-					"default" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 1.35; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, First. <i>Title 2</i>. Edited by Ed McEditor, 2014.</div></div></content>',
-					"apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (2014). <i>Title 2</i>. (E. McEditor, Ed.).</div></div></content>',
-					"https://www.zotero.org/styles/apa" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (2014). <i>Title 2</i>. (E. McEditor, Ed.).</div></div></content>',
-					"https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl" => '<content xmlns:zapi="http://zotero.org/ns/api" zapi:type="bib" type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml" class="csl-bib-body" style="line-height: 1.35; "><div class="csl-entry" style="clear: left; "><div class="csl-left-margin" style="float: left; padding-right: 0.5em; text-align: right; width: 1em;">[1]</div><div class="csl-right-inline" style="margin: 0 .4em 0 1.5em;">F. Last, <i>Title 2</i>. 2014.</div></div></div></content>'
-				)
-			]
-		];
-		
-		self::$multiResponses = [
-			"default" => '<?xml version="1.0"?><div class="csl-bib-body" style="line-height: 1.35; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, First. <i>Title</i>, 2014.</div><div class="csl-entry">———. <i>Title 2</i>. Edited by Ed McEditor, 2014.</div></div>',
-			"apa" => '<?xml version="1.0"?><div class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (2014a). <i>Title</i>.</div><div class="csl-entry">Last, F. (2014b). <i>Title 2</i>. (E. McEditor, Ed.).</div></div>',
-			"https://www.zotero.org/styles/apa" => '<?xml version="1.0"?><div class="csl-bib-body" style="line-height: 2; padding-left: 2em; text-indent:-2em;"><div class="csl-entry">Last, F. (2014a). <i>Title</i>.</div><div class="csl-entry">Last, F. (2014b). <i>Title 2</i>. (E. McEditor, Ed.).</div></div>',
-			"https://raw.githubusercontent.com/citation-style-language/styles/master/ieee.csl" => '<?xml version="1.0"?><div class="csl-bib-body" style="line-height: 1.35; "><div class="csl-entry" style="clear: left; "><div class="csl-left-margin" style="float: left; padding-right: 0.5em; text-align: right; width: 1em;">[1]</div><div class="csl-right-inline" style="margin: 0 .4em 0 1.5em;">F. Last, <i>Title 2</i>. 2014.</div></div><div class="csl-entry" style="clear: left; "><div class="csl-left-margin" style="float: left; padding-right: 0.5em; text-align: right; width: 1em;">[2]</div><div class="csl-right-inline" style="margin: 0 .4em 0 1.5em;">F. Last, <i>Title</i>. 2014.</div></div></div>'
-		];
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	// JSON
-	public function testIncludeCitationSingle() {
-		foreach (self::$styles as $style) {
-			foreach (self::$items as $key => $expected) {
-				$response = API::userGet(
-					self::$config['userID'],
-					"items/$key?include=citation"
-						. ($style == "default" ? "" : "&style=" . urlencode($style))
-				);
-				$this->assert200($response);
-				$json = API::getJSONFromResponse($response);
-				$this->assertEquals($expected['json']['citation'][$style], $json['citation'], "Item: $key, style: $style");
-			}
-		}
-	}
-	
-	
-	// Atom
-	public function testContentCitationSingle() {
-		foreach (self::$styles as $style) {
-			foreach (self::$items as $key => $expected) {
-				$response = API::userGet(
-					self::$config['userID'],
-					"items/$key?content=citation"
-						. ($style == "default" ? "" : "&style=" . urlencode($style))
-				);
-				$this->assert200($response);
-				$content = API::getContentFromResponse($response);
-				// Add zapi namespace
-				$content = str_replace('<content ', '<content xmlns:zapi="http://zotero.org/ns/api" ', $content);
-				$this->assertXmlStringEqualsXmlString($expected['atom']['citation'][$style], $content);
-			}
-		}
-	}
-	
-	
-	// JSON
-	public function testIncludeCitationMulti() {
-		$keys = array_keys(self::$items);
-		$keyStr = implode(',', $keys);
-		
-		foreach (self::$styles as $style) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items?itemKey=$keyStr&include=citation"
-					. ($style == "default" ? "" : "&style=" . urlencode($style))
-			);
-			$this->assert200($response);
-			$this->assertTotalResults(sizeOf($keys), $response);
-			$json = API::getJSONFromResponse($response);
-			
-			foreach ($json as $item) {
-				$key = $item['key'];
-				$content = $item['citation'];
-				
-				$this->assertEquals(self::$items[$key]['json']['citation'][$style], $content);
-			}
-		}
-	}
-	
-	
-	// Atom
-	public function testContentCitationMulti() {
-		$keys = array_keys(self::$items);
-		$keyStr = implode(',', $keys);
-		
-		foreach (self::$styles as $style) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items?itemKey=$keyStr&content=citation"
-					. ($style == "default" ? "" : "&style=" . urlencode($style))
-			);
-			$this->assert200($response);
-			$this->assertTotalResults(sizeOf($keys), $response);
-			$xml = API::getXMLFromResponse($response);
-			
-			$entries = $xml->xpath('//atom:entry');
-			foreach ($entries as $entry) {
-				$key = (string) $entry->children("http://zotero.org/ns/api")->key;
-				$content = $entry->content->asXML();
-				
-				// Add zapi namespace
-				$content = str_replace('<content ', '<content xmlns:zapi="http://zotero.org/ns/api" ', $content);
-				$this->assertXmlStringEqualsXmlString(self::$items[$key]['atom']['citation'][$style], $content);
-			}
-		}
-	}
-	
-	
-	// JSON
-	public function testIncludeBibSingle() {
-		foreach (self::$styles as $style) {
-			foreach (self::$items as $key => $expected) {
-				$response = API::userGet(
-					self::$config['userID'],
-					"items/$key?include=bib"
-						. ($style == "default" ? "" : "&style=" . urlencode($style))
-				);
-				$this->assert200($response);
-				$json = API::getJSONFromResponse($response);
-				$this->assertXmlStringEqualsXmlString($expected['json']['bib'][$style], $json['bib']);
-			}
-		}
-	}
-	
-	
-	// Atom
-	public function testContentBibSingle() {
-		foreach (self::$styles as $style) {
-			foreach (self::$items as $key => $expected) {
-				$response = API::userGet(
-					self::$config['userID'],
-					"items/$key?content=bib"
-						. ($style == "default" ? "" : "&style=" . urlencode($style))
-				);
-				$this->assert200($response);
-				$content = API::getContentFromResponse($response);
-				// Add zapi namespace
-				$content = str_replace('<content ', '<content xmlns:zapi="http://zotero.org/ns/api" ', $content);
-				$this->assertXmlStringEqualsXmlString($expected['atom']['bib'][$style], $content);
-			}
-		}
-	}
-	
-	
-	// JSON
-	public function testIncludeBibMulti() {
-		$keys = array_keys(self::$items);
-		$keyStr = implode(',', $keys);
-		
-		foreach (self::$styles as $style) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items?itemKey=$keyStr&include=bib"
-					. ($style == "default" ? "" : "&style=" . urlencode($style))
-			);
-			$this->assert200($response);
-			$this->assertTotalResults(sizeOf($keys), $response);
-			$json = API::getJSONFromResponse($response);
-			
-			foreach ($json as $item) {
-				$key = $item['key'];
-				$this->assertXmlStringEqualsXmlString(self::$items[$key]['json']['bib'][$style], $item['bib']);
-			}
-		}
-	}
-	
-	
-	// Atom
-	public function testContentBibMulti() {
-		$keys = array_keys(self::$items);
-		$keyStr = implode(',', $keys);
-		
-		foreach (self::$styles as $style) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items?itemKey=$keyStr&content=bib"
-					. ($style == "default" ? "" : "&style=" . urlencode($style))
-			);
-			$this->assert200($response);
-			$xml = API::getXMLFromResponse($response);
-			$this->assertTotalResults(sizeOf($keys), $response);
-			
-			$entries = $xml->xpath('//atom:entry');
-			foreach ($entries as $entry) {
-				$key = (string) $entry->children("http://zotero.org/ns/api")->key;
-				$content = $entry->content->asXML();
-				
-				// Add zapi namespace
-				$content = str_replace('<content ', '<content xmlns:zapi="http://zotero.org/ns/api" ', $content);
-				$this->assertXmlStringEqualsXmlString(self::$items[$key]['atom']['bib'][$style], $content);
-			}
-		}
-	}
-	
-	
-	public function testFormatBibMultiple() {
-		foreach (self::$styles as $style) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items?format=bib" . ($style == "default" ? "" : "&style=" . urlencode($style))
-			);
-			$this->assert200($response);
-			$this->assertXmlStringEqualsXmlString(self::$multiResponses[$style], $response->getBody());
-		}
-	}
-}
diff --git a/tests/remote/tests/API/3/CacheTest.php b/tests/remote/tests/API/3/CacheTest.php
deleted file mode 100644
index fad5c2b4..00000000
--- a/tests/remote/tests/API/3/CacheTest.php
+++ /dev/null
@@ -1,77 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class CacheTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	/**
-	 * An object type's primary data cache for a library has to be created before
-	 * 
-	 */
-	public function testCacheCreatorPrimaryData() {
-		$data = array(
-			"title" => "Title",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				),
-				array(
-					"creatorType" => "editor",
-					"firstName" => "Ed",
-					"lastName" => "McEditor"
-				)
-			)
-		);
-		
-		$key = API::createItem("book", $data, $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?content=csljson"
-		);
-		$json = json_decode(API::getContentFromResponse($response));
-		$this->assertEquals("First", $json->author[0]->given);
-		$this->assertEquals("Last", $json->author[0]->family);
-		$this->assertEquals("Ed", $json->editor[0]->given);
-		$this->assertEquals("McEditor", $json->editor[0]->family);
-	}
-}
diff --git a/tests/remote/tests/API/3/CollectionTest.php b/tests/remote/tests/API/3/CollectionTest.php
deleted file mode 100644
index 5e68fa3e..00000000
--- a/tests/remote/tests/API/3/CollectionTest.php
+++ /dev/null
@@ -1,473 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class CollectionTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testNewCollection() {
-		$name = "Test Collection";
-		$json = API::createCollection($name, false, $this, 'json');
-		$this->assertEquals($name, (string) $json['data']['name']);
-		return $json['key'];
-	}
-	
-	
-	/**
-	 * @depends testNewCollection
-	 */
-	public function testNewSubcollection($parent) {
-		$name = "Test Subcollection";
-		
-		$json = API::createCollection($name, $parent, $this, 'json');
-		$this->assertEquals($name, (string) $json['data']['name']);
-		$this->assertEquals($parent, (string) $json['data']['parentCollection']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$parent"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals(1, (int) $json['meta']['numCollections']);
-	}
-	
-	
-	public function testNewMultipleCollections() {
-		$json = API::createCollection("Test Collection 1", false, $this, 'jsonData');
-		
-		$name1 = "Test Collection 2";
-		$name2 = "Test Subcollection";
-		$parent2 = $json['key'];
-		
-		$json = [
-			[
-				'name' => $name1
-			],
-			[
-				'name' => $name2,
-				'parentCollection' => $parent2
-			]
-		];
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections",
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json['successful']);
-		// Deprecated
-		$this->assertCount(2, $json['success']);
-		
-		// Check data in write response
-		$this->assertEquals($json['successful'][0]['key'], $json['successful'][0]['data']['key']);
-		$this->assertEquals($json['successful'][1]['key'], $json['successful'][1]['data']['key']);
-		$this->assertEquals($libraryVersion, $json['successful'][0]['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][1]['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][0]['data']['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][1]['data']['version']);
-		$this->assertEquals($name1, $json['successful'][0]['data']['name']);
-		$this->assertEquals($name2, $json['successful'][1]['data']['name']);
-		$this->assertFalse($json['successful'][0]['data']['parentCollection']);
-		$this->assertEquals($parent2, $json['successful'][1]['data']['parentCollection']);
-		
-		// Check in separate request, to be safe
-		$keys = array_map(function ($o) {
-			return $o['key'];
-		}, $json['successful']);
-		$response = API::getCollectionResponse($keys);
-		$this->assertTotalResults(2, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($name1, $json[0]['data']['name']);
-		$this->assertFalse($json[0]['data']['parentCollection']);
-		$this->assertEquals($name2, $json[1]['data']['name']);
-		$this->assertEquals($parent2, $json[1]['data']['parentCollection']);
-	}
-	
-	
-	public function testCreateKeyedCollections() {
-		require_once '../../model/ID.inc.php';
-		$key1 = \Zotero_ID::getKey();
-		$name1 = "Test Collection 2";
-		$name2 = "Test Subcollection";
-		
-		$json = [
-			[
-				'key' => $key1,
-				'version' => 0,
-				'name' => $name1
-			],
-			[
-				'name' => $name2,
-				'parentCollection' => $key1
-			]
-		];
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections",
-			json_encode($json),
-			["Content-Type: application/json"]
-		);
-		$this->assert200($response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json['successful']);
-		
-		// Check data in write response
-		$this->assertEquals($json['successful'][0]['key'], $json['successful'][0]['data']['key']);
-		$this->assertEquals($json['successful'][1]['key'], $json['successful'][1]['data']['key']);
-		$this->assertEquals($libraryVersion, $json['successful'][0]['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][1]['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][0]['data']['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][1]['data']['version']);
-		$this->assertEquals($name1, $json['successful'][0]['data']['name']);
-		$this->assertEquals($name2, $json['successful'][1]['data']['name']);
-		$this->assertFalse($json['successful'][0]['data']['parentCollection']);
-		$this->assertEquals($key1, $json['successful'][1]['data']['parentCollection']);
-		
-		// Check in separate request, to be safe
-		$keys = array_map(function ($o) {
-			return $o['key'];
-		}, $json['successful']);
-		$response = API::getCollectionResponse($keys);
-		$this->assertTotalResults(2, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($name1, $json[0]['data']['name']);
-		$this->assertFalse($json[0]['data']['parentCollection']);
-		$this->assertEquals($name2, $json[1]['data']['name']);
-		$this->assertEquals($key1, $json[1]['data']['parentCollection']);
-	}
-	
-	
-	public function testUpdateMultipleCollections() {
-		$collection1Data = API::createCollection("Test 1", false, $this, 'jsonData');
-		$collection2Name = "Test 2";
-		$collection2Data = API::createCollection($collection2Name, false, $this, 'jsonData');
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Update with no change, which should still update library version (for now)
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections",
-			json_encode([
-				$collection1Data,
-				$collection2Data
-			]),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert200($response);
-		// If this behavior changes, remove the pre-increment
-		$this->assertEquals(++$libraryVersion, $response->getHeader("Last-Modified-Version"));
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json['unchanged']);
-		
-		$this->assertEquals($libraryVersion, API::getLibraryVersion());
-		
-		// Update
-		$collection1NewName = "Test 1 Modified";
-		$collection2NewParentKey = API::createCollection("Test 3", false, $this, 'key');
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections",
-			json_encode([
-				[
-					'key' => $collection1Data['key'],
-					'version' => $collection1Data['version'],
-					'name' => $collection1NewName
-				],
-				[
-					'key' => $collection2Data['key'],
-					'version' => $collection2Data['version'],
-					'parentCollection' => $collection2NewParentKey
-				]
-			]),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert200($response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json['successful']);
-		// Deprecated
-		$this->assertCount(2, $json['success']);
-		
-		// Check data in write response
-		$this->assertEquals($json['successful'][0]['key'], $json['successful'][0]['data']['key']);
-		$this->assertEquals($json['successful'][1]['key'], $json['successful'][1]['data']['key']);
-		$this->assertEquals($libraryVersion, $json['successful'][0]['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][1]['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][0]['data']['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][1]['data']['version']);
-		$this->assertEquals($collection1NewName, $json['successful'][0]['data']['name']);
-		$this->assertEquals($collection2Name, $json['successful'][1]['data']['name']);
-		$this->assertFalse($json['successful'][0]['data']['parentCollection']);
-		$this->assertEquals($collection2NewParentKey, $json['successful'][1]['data']['parentCollection']);
-		
-		// Check in separate request, to be safe
-		$keys = array_map(function ($o) {
-			return $o['key'];
-		}, $json['successful']);
-		$response = API::getCollectionResponse($keys);
-		$this->assertTotalResults(2, $response);
-		$json = API::getJSONFromResponse($response);
-		// POST follows PATCH behavior, so unspecified values shouldn't change
-		$this->assertEquals($collection1NewName, $json[0]['data']['name']);
-		$this->assertFalse($json[0]['data']['parentCollection']);
-		$this->assertEquals($collection2Name, $json[1]['data']['name']);
-		$this->assertEquals($collection2NewParentKey, $json[1]['data']['parentCollection']);
-	}
-	
-	
-	public function testCollectionItemChange() {
-		$collectionKey1 = API::createCollection('Test', false, $this, 'key');
-		$collectionKey2 = API::createCollection('Test', false, $this, 'key');
-		
-		$json = API::createItem("book", array(
-			'collections' => array($collectionKey1)
-		), $this, 'json');
-		$itemKey1 = $json['key'];
-		$itemVersion1 = $json['version'];
-		$this->assertEquals([$collectionKey1], $json['data']['collections']);
-		
-		$json = API::createItem("journalArticle", array(
-			'collections' => array($collectionKey2)
-		), $this, 'json');
-		$itemKey2 = $json['key'];
-		$itemVersion2 = $json['version'];
-		$this->assertEquals([$collectionKey2], $json['data']['collections']);
-		
-		$json = API::getCollection($collectionKey1, $this);
-		$this->assertEquals(1, $json['meta']['numItems']);
-		
-		$json = API::getCollection($collectionKey2, $this);
-		$this->assertEquals(1, $json['meta']['numItems']);
-		$collectionData2 = $json['data'];
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Add items to collection
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$itemKey1",
-			json_encode(array(
-				"collections" => array($collectionKey1, $collectionKey2)
-			)),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $itemVersion1"
-			)
-		);
-		$this->assert204($response);
-		
-		// Item version should change
-		$json = API::getItem($itemKey1, $this);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-		
-		// Collection timestamp shouldn't change, but numItems should
-		$json = API::getCollection($collectionKey2, $this);
-		$this->assertEquals(2, $json['meta']['numItems']);
-		$this->assertEquals($collectionData2['version'], $json['version']);
-		$collectionData2 = $json['data'];
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Remove collections
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$itemKey2",
-			json_encode(array(
-				"collections" => array()
-			)),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $itemVersion2"
-			)
-		);
-		$this->assert204($response);
-		
-		// Item version should change
-		$json = API::getItem($itemKey2, $this);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-		
-		// Collection timestamp shouldn't change, but numItems should
-		$json = API::getCollection($collectionKey2, $this);
-		$this->assertEquals(1, $json['meta']['numItems']);
-		$this->assertEquals($collectionData2['version'], $json['version']);
-		
-		// Check collections arrays and numItems
-		$json = API::getItem($itemKey1, $this);
-		$this->assertCount(2, $json['data']['collections']);
-		$this->assertContains($collectionKey1, $json['data']['collections']);
-		$this->assertContains($collectionKey2, $json['data']['collections']);
-		
-		$json = API::getItem($itemKey2, $this);
-		$this->assertCount(0, $json['data']['collections']);
-		
-		$json = API::getCollection($collectionKey1, $this);
-		$this->assertEquals(1, $json['meta']['numItems']);
-		
-		$json = API::getCollection($collectionKey2, $this);
-		$this->assertEquals(1, $json['meta']['numItems']);
-	}
-	
-	
-	public function testCollectionChildItemError() {
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		
-		$key = API::createItem("book", array(), $this, 'key');
-		$json = API::createNoteItem("Test Note", $key, $this, 'jsonData');
-		$json['collections'] = [$collectionKey];
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$json['key']}",
-			json_encode($json),
-			array(
-				"Content-Type: application/json"
-			)
-		);
-		$this->assert400($response);
-		$this->assertEquals("Child items cannot be assigned to collections", $response->getBody());
-	}
-	
-	
-	public function testCollectionItems() {
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		
-		$json = API::createItem("book", ['collections' => [$collectionKey]], $this, 'jsonData');
-		$itemKey1 = $json['key'];
-		$itemVersion1 = $json['version'];
-		$this->assertEquals([$collectionKey], $json['collections']);
-		
-		$json = API::createItem("journalArticle", ['collections' => [$collectionKey]], $this, 'jsonData');
-		$itemKey2 = $json['key'];
-		$itemVersion2 = $json['version'];
-		$this->assertEquals([$collectionKey], $json['collections']);
-		
-		$childItemKey1 = API::createAttachmentItem("linked_url", [], $itemKey1, $this, 'key');
-		$childItemKey2 = API::createAttachmentItem("linked_url", [], $itemKey2, $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items?format=keys"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(4, $keys);
-		$this->assertContains($itemKey1, $keys);
-		$this->assertContains($itemKey2, $keys);
-		$this->assertContains($childItemKey1, $keys);
-		$this->assertContains($childItemKey2, $keys);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?format=keys"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($itemKey1, $keys);
-		$this->assertContains($itemKey2, $keys);
-	}
-	
-	public function testCollectionItemMissingCollection() {
-		$response = API::createItem("book", ['collections' => ["AAAAAAAA"]], $this, 'response');
-		$this->assert409ForObject($response, "Collection AAAAAAAA not found");
-	}
-	
-	
-	public function test_should_return_409_on_missing_parent_collection() {
-		$missingCollectionKey = "GDHRG8AZ";
-		$json = API::createCollection("Test", [ 'parentCollection' => $missingCollectionKey ], $this);
-		$this->assert409ForObject($json, "Parent collection $missingCollectionKey not found");
-		$this->assertEquals($missingCollectionKey, $json['failed'][0]['data']['collection']);
-	}
-	
-	
-	public function test_should_return_413_if_collection_name_is_too_long() {
-		$content = str_repeat("1", 256);
-		$json = [
-			"name" => $content
-		];
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert413ForObject($response);
-	}
-	
-	
-	public function test_should_delete_collection_with_15_levels_below_it() {
-		$json = API::createCollection("0", false, $this, 'json');
-		$topCollectionKey = $json['key'];
-		$parentCollectionKey = $topCollectionKey;
-		for ($i = 0; $i < 15; $i++) {
-			$json = API::createCollection("$i", $parentCollectionKey, $this, 'json');
-			$parentCollectionKey = $json['key'];
-		}
-		$response = API::userDelete(
-			self::$config['userID'],
-			"collections?collectionKey=$topCollectionKey",
-			[
-				"If-Unmodified-Since-Version: {$json['version']}"
-			]
-		);
-		$this->assert204($response);
-	}
-	
-	
-	public function test_should_allow_emoji_in_name() {
-		$name = "🐶"; // 4-byte character
-		$json = API::createCollection($name, false, $this, 'json');
-		$this->assertEquals($name, (string) $json['data']['name']);
-	}
-}
-?>
diff --git a/tests/remote/tests/API/3/CreatorTest.php b/tests/remote/tests/API/3/CreatorTest.php
deleted file mode 100644
index 7c69a7c6..00000000
--- a/tests/remote/tests/API/3/CreatorTest.php
+++ /dev/null
@@ -1,194 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class CreatorTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testCreatorSummaryJSON() {
-		$json = API::createItem("book", array(
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"name" => "Test"
-				)
-			)
-		), $this, 'json');
-		$itemKey = $json['key'];
-		
-		$this->assertEquals("Test", $json['meta']['creatorSummary']);
-		
-		$json = $json['data'];
-		$json['creators'][] = [
-			"creatorType" => "author",
-			"firstName" => "Alice",
-			"lastName" => "Foo"
-		];
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$itemKey",
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		$json = API::getItem($itemKey, $this, 'json');
-		$this->assertEquals("Test and Foo", $json['meta']['creatorSummary']);
-		
-		$json = $json['data'];
-		$json['creators'][] = array(
-			"creatorType" => "author",
-			"firstName" => "Bob",
-			"lastName" => "Bar"
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$itemKey",
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		$json = API::getItem($itemKey, $this, 'json');
-		$this->assertEquals("Test et al.", $json['meta']['creatorSummary']);
-	}
-	
-	
-	public function testCreatorSummaryAtom() {
-		$xml = API::createItem("book", array(
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"name" => "Test"
-				)
-			)
-		), $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$itemKey = $data['key'];
-		$json = json_decode($data['content'], true);
-		
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("Test", $creatorSummary);
-		
-		$json['creators'][] = array(
-			"creatorType" => "author",
-			"firstName" => "Alice",
-			"lastName" => "Foo"
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$itemKey",
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		$xml = API::getItemXML($itemKey);
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("Test and Foo", $creatorSummary);
-		
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		
-		$json['creators'][] = array(
-			"creatorType" => "author",
-			"firstName" => "Bob",
-			"lastName" => "Bar"
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$itemKey",
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		$xml = API::getItemXML($itemKey);
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("Test et al.", $creatorSummary);
-	}
-	
-	
-	public function testEmptyCreator() {
-		$json = API::createItem("book", array(
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"name" => chr(0xEF) . chr(0xBB) . chr(0xBF)
-				)
-			)
-		), $this, 'json');
-		$this->assertArrayNotHasKey('creatorSummary', $json['meta']);
-	}
-	
-	
-	public function testCreatorCaseSensitivity() {
-		API::createItem("book", array(
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"name" => "SMITH"
-				)
-			)
-		), $this, 'json');
-		$json = API::createItem("book", array(
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"name" => "Smith"
-				)
-			)
-		), $this, 'json');
-		$this->assertEquals('Smith', $json['data']['creators'][0]['name']);
-	}
-	
-	
-	public function test_should_allow_emoji_in_creator_name() {
-		$char = "🐻"; // 4-byte character
-		$json = API::createItem("book", [
-			"creators" => [
-				[
-					"creatorType" => "author",
-					"name" => $char
-				]
-			]
-		], $this, 'json');
-		$this->assertEquals($char, $json['data']['creators'][0]['name']);
-	}
-}
diff --git a/tests/remote/tests/API/3/ExportTest.php b/tests/remote/tests/API/3/ExportTest.php
deleted file mode 100644
index e4551e28..00000000
--- a/tests/remote/tests/API/3/ExportTest.php
+++ /dev/null
@@ -1,197 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2014 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class ExportTests extends APITests {
-	private static $items;
-	private static $multiResponses = [];
-	private static $formats = ['bibtex', 'ris', 'csljson'];
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-		
-		// Create test data
-		$key = API::createItem("book", array(
-			"title" => "Title",
-			"date" => "January 1, 2014",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				)
-			)
-		), null, 'key');
-		self::$items[$key] = [
-			'bibtex' => "\n@book{last_title_2014,\n	title = {Title},\n	author = {Last, First},\n	month = jan,\n	year = {2014}\n}",
-			'ris' => "TY  - BOOK\r\nTI  - Title\r\nAU  - Last, First\r\nDA  - 2014/01/01/\r\nPY  - 2014\r\nER  - \r\n\r\n",
-			'csljson' => [
-				'id' => self::$config['libraryID'] . "/$key",
-				'type' => 'book',
-				'title' => 'Title',
-				'author' => [
-					[
-						'family' => 'Last',
-						'given' => 'First'
-					]
-				],
-				'issued' => [
-					'date-parts' => [
-						['2014', 1, 1]
-					]
-				]
-			]
-		];
-		
-		$key = API::createItem("book", array(
-			"title" => "Title 2",
-			"date" => "June 24, 2014",
-			"creators" => array(
-				array(
-					"creatorType" => "author",
-					"firstName" => "First",
-					"lastName" => "Last"
-				),
-				array(
-					"creatorType" => "editor",
-					"firstName" => "Ed",
-					"lastName" => "McEditor"
-				)
-			)
-		), null, 'key');
-		self::$items[$key] = [
-			'bibtex' => "\n@book{last_title_2014,\n	title = {Title 2},\n	author = {Last, First},\n	editor = {McEditor, Ed},\n	month = jun,\n	year = {2014}\n}",
-			'ris' => "TY  - BOOK\r\nTI  - Title 2\r\nAU  - Last, First\r\nA3  - McEditor, Ed\r\nDA  - 2014/06/24/\r\nPY  - 2014\r\nER  - \r\n\r\n",
-			'csljson' => [
-				'id' => self::$config['libraryID'] . "/$key",
-				'type' => 'book',
-				'title' => 'Title 2',
-				'author' => [
-					[
-						'family' => 'Last',
-						'given' => 'First'
-					]
-				],
-				'editor' => [
-					[
-						'family' => 'McEditor',
-						'given' => 'Ed'
-					]
-				],
-				'issued' => [
-					'date-parts' => [
-						['2014', 6, 24]
-					]
-				]
-			]
-		];
-		
-		self::$multiResponses = [
-			'bibtex' => [
-				"contentType" => "application/x-bibtex",
-				"content" => "\n@book{last_title_2014,\n	title = {Title 2},\n	author = {Last, First},\n	editor = {McEditor, Ed},\n	month = jun,\n	year = {2014}\n}\n\n@book{last_title_2014-1,\n	title = {Title},\n	author = {Last, First},\n	month = jan,\n	year = {2014}\n}",
-			],
-			'ris' => [
-				"contentType" => "application/x-research-info-systems",
-				"content" => "TY  - BOOK\r\nTI  - Title 2\r\nAU  - Last, First\r\nA3  - McEditor, Ed\r\nDA  - 2014/06/24/\r\nPY  - 2014\r\nER  - \r\n\r\nTY  - BOOK\r\nTI  - Title\r\nAU  - Last, First\r\nDA  - 2014/01/01/\r\nPY  - 2014\r\nER  - \r\n\r\n"
-			],
-			'csljson' => [
-				"contentType" => "application/vnd.citationstyles.csl+json",
-				"content" => [
-					'items' => [
-						self::$items[array_keys(self::$items)[1]]['csljson'],
-						self::$items[array_keys(self::$items)[0]]['csljson']
-					]
-				]
-			]
-		];
-	}
-	
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testExportInclude() {
-		foreach (self::$formats as $format) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items?include=$format"
-			);
-			$this->assert200($response);
-			$json = API::getJSONFromResponse($response);
-			foreach ($json as $obj) {
-				$this->assertEquals(self::$items[$obj['key']][$format], $obj[$format]);
-			}
-		}
-	}
-	
-	
-	public function testExportFormatSingle() {
-		foreach (self::$formats as $format) {
-			foreach (self::$items as $key => $expected) {
-				$response = API::userGet(
-					self::$config['userID'],
-					"items/$key?format=$format"
-				);
-				$this->assert200($response);
-				$body = $response->getBody();
-				if (is_array($expected[$format])) {
-					$body = json_decode($body, true);
-				}
-				// TODO: Remove in APIv4
-				if ($format == 'csljson') {
-					$body = $body['items'][0];
-				}
-				$this->assertEquals($expected[$format], $body);
-			}
-		}
-	}
-	
-	
-	public function testExportFormatMultiple() {
-		foreach (self::$formats as $format) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items?format=$format"
-			);
-			$this->assert200($response);
-			$this->assertContentType(self::$multiResponses[$format]['contentType'], $response);
-			$body = $response->getBody();
-			if (is_array(self::$multiResponses[$format]['content'])) {
-				$body = json_decode($body, true);
-			}
-			$this->assertEquals(self::$multiResponses[$format]['content'], $body);
-		}
-	}
-}
diff --git a/tests/remote/tests/API/3/FileTest.php b/tests/remote/tests/API/3/FileTest.php
deleted file mode 100644
index 9128673d..00000000
--- a/tests/remote/tests/API/3/FileTest.php
+++ /dev/null
@@ -1,2014 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API, HTTP, SimpleXMLElement, Sync, Z_Tests;
-require_once 'APITests.inc.php';
-require_once 'include/bootstrap.inc.php';
-
-/**
- * @group s3
- */
-class FileTests extends APITests {
-	private static $toDelete = array();
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public function setUp() {
-		parent::setUp();
-		
-		// Delete work files
-		$delete = array("file", "old", "new", "patch");
-		foreach ($delete as $file) {
-			if (file_exists("work/$file")) {
-				unlink("work/$file");
-			}
-		}
-		clearstatcache();
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		
-		$s3Client = Z_Tests::$AWS->createS3();
-		
-		foreach (self::$toDelete as $file) {
-			try {
-				$s3Client->deleteObject([
-					'Bucket' => self::$config['s3Bucket'],
-					'Key' => $file
-				]);
-			}
-			catch (\Aws\S3\Exception\S3Exception $e) {
-				if ($e->getAwsErrorCode() == 'NoSuchKey') {
-					echo "\n$file not found on S3 to delete\n";
-				}
-				else {
-					throw $e;
-				}
-			}
-		}
-	}
-	
-	
-	public function testNewEmptyImportedFileAttachmentItem() {
-		return API::createAttachmentItem("imported_file", [], false, $this, 'key');
-	}
-	
-	
-	/**
-	 * Test errors getting file upload authorization via form data
-	 *
-	 * @depends testNewEmptyImportedFileAttachmentItem
-	 */
-	public function testAddFileFormDataAuthorizationErrors($parentKey) {
-		$fileContents = self::getRandomUnicodeString();
-		$hash = md5($fileContents);
-		$mtime = time() * 1000;
-		$size = strlen($fileContents);
-		$filename = "test_" . $fileContents;
-		
-		$fileParams = array(
-			"md5" => $hash,
-			"filename" => $filename,
-			"filesize" => $size,
-			"mtime" => $mtime,
-			"contentType" => "text/plain",
-			"charset" => "utf-8"
-		);
-		
-		// Check required params
-		foreach (array("md5", "filename", "filesize", "mtime") as $exclude) {
-			$response = API::userPost(
-				self::$config['userID'],
-				"items/$parentKey/file",
-				$this->implodeParams($fileParams, array($exclude)),
-				array(
-					"Content-Type: application/x-www-form-urlencoded",
-					"If-None-Match: *"
-				)
-			);
-			$this->assert400($response);
-		}
-		
-		// Seconds-based mtime
-		$fileParams2 = $fileParams;
-		$fileParams2['mtime'] = round($mtime / 1000);
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$parentKey/file",
-			$this->implodeParams($fileParams2),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		// TODO: Enable this test when the dataserver enforces it
-		//$this->assert400($response);
-		//$this->assertEquals('mtime must be specified in milliseconds', $response->getBody());
-		
-		$fileParams = $this->implodeParams($fileParams);
-		
-		// Invalid If-Match
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$parentKey/file",
-			$fileParams,
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: " . md5("invalidETag")
-			)
-		);
-		$this->assert412($response);
-		
-		// Missing If-None-Match
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$parentKey/file",
-			$fileParams,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			)
-		);
-		$this->assert428($response);
-		
-		// Invalid If-None-Match
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$parentKey/file",
-			$fileParams,
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: invalidETag"
-			)
-		);
-		$this->assert400($response);
-	}
-	
-	
-	public function testAddFileFormDataFull() {
-		$parentKey = API::createItem("book", false, $this, 'key');
-		
-		$json = API::createAttachmentItem("imported_file", [], $parentKey, $this, 'json');
-		$attachmentKey = $json['key'];
-		$originalVersion = $json['version'];
-		
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = filesize($file);
-		$contentType = "text/plain";
-		$charset = "utf-8";
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$attachmentKey/file",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType,
-				"charset" => $charset
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody());
-		$this->assertNotNull($json);
-		
-		self::$toDelete[] = "$hash";
-		
-		// Upload wrong contents to S3
-		$response = HTTP::post(
-			$json->url,
-			$json->prefix . strrev($fileContents) . $json->suffix,
-			[
-				"Content-Type: " . $json->contentType
-			]
-		);
-		$this->assert400($response);
-		$this->assertContains(
-			"The Content-MD5 you specified did not match what we received.", $response->getBody()
-		);
-		
-		// Upload to S3
-		$response = HTTP::post(
-			$json->url,
-			$json->prefix . $fileContents . $json->suffix,
-			[
-				"Content-Type: " . $json->contentType
-			]
-		);
-		$this->assert201($response);
-		
-		//
-		// Register upload
-		//
-		
-		// No If-None-Match
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$attachmentKey/file",
-			"upload=" . $json->uploadKey,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			)
-		);
-		$this->assert428($response);
-		
-		// Invalid upload key
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$attachmentKey/file",
-			"upload=invalidUploadKey",
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert400($response);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$attachmentKey/file",
-			"upload=" . $json->uploadKey,
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert204($response);
-		
-		// Verify attachment item metadata
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$attachmentKey"
-		);
-		$json = API::getJSONFromResponse($response)['data'];
-		
-		$this->assertEquals($hash, $json['md5']);
-		$this->assertEquals($filename, $json['filename']);
-		$this->assertEquals($mtime, $json['mtime']);
-		$this->assertEquals($contentType, $json['contentType']);
-		$this->assertEquals($charset, $json['charset']);
-		
-		return array(
-			"key" => $attachmentKey,
-			"json" => $json,
-			"size" => $size
-		);
-	}
-	
-	/**
-	 * @group classic-sync
-	 */
-	public function testAddFileFormDataFullParams() {
-		$json = API::createAttachmentItem("imported_file", [], false, $this, 'jsonData');
-		$attachmentKey = $json['key'];
-		
-		// Get serverDateModified
-		$serverDateModified = $json['dateAdded'];
-		sleep(1);
-		
-		$originalVersion = $json['version'];
-		
-		// Get a sync timestamp from before the file is updated
-		require_once 'include/sync.inc.php';
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID);
-		$lastsync = (int) $xml['timestamp'];
-		Sync::logout($sessionID);
-		
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = filesize($file);
-		$contentType = "text/plain";
-		$charset = "utf-8";
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$attachmentKey/file",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType,
-				"charset" => $charset,
-				"params" => 1
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody());
-		$this->assertNotNull($json);
-		
-		self::$toDelete[] = "$hash";
-		
-		// Generate form-data -- taken from S3::getUploadPostData()
-		$boundary = "---------------------------" . md5(uniqid());
-		$prefix = "";
-		foreach ($json->params as $key => $val) {
-			$prefix .= "--$boundary\r\n"
-				. "Content-Disposition: form-data; name=\"$key\"\r\n\r\n"
-				. $val . "\r\n";
-		}
-		$prefix .= "--$boundary\r\nContent-Disposition: form-data; name=\"file\"\r\n\r\n";
-		$suffix = "\r\n--$boundary--";
-		
-		// Upload to S3
-		$response = HTTP::post(
-			$json->url,
-			$prefix . $fileContents . $suffix,
-			array(
-				"Content-Type: multipart/form-data; boundary=$boundary"
-			)
-		);
-		$this->assert201($response);
-		
-		//
-		// Register upload
-		//
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$attachmentKey/file",
-			"upload=" . $json->uploadKey,
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert204($response);
-		
-		// Verify attachment item metadata
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$attachmentKey"
-		);
-		$json = API::getJSONFromResponse($response)['data'];
-		
-		$this->assertEquals($hash, $json['md5']);
-		$this->assertEquals($filename, $json['filename']);
-		$this->assertEquals($mtime, $json['mtime']);
-		$this->assertEquals($contentType, $json['contentType']);
-		$this->assertEquals($charset, $json['charset']);
-		
-		// Make sure version has changed
-		$this->assertNotEquals($originalVersion, $json['version']);
-		
-		// Make sure new attachment is passed via sync
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, $lastsync);
-		Sync::logout($sessionID);
-		$this->assertGreaterThan(0, $xml->updated[0]->count());
-	}
-	
-	
-	/**
-	 * @depends testAddFileFormDataFull
-	 */
-	public function testAddFileExisting($addFileData) {
-		$key = $addFileData['key'];
-		$json = $addFileData['json'];
-		$md5 = $json['md5'];
-		$size = $addFileData['size'];
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams(array(
-				"md5" => $json['md5'],
-				"filename" => $json['filename'],
-				"filesize" => $size,
-				"mtime" => $json['mtime'],
-				"contentType" => $json['contentType'],
-				"charset" => $json['charset']
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: " . $json['md5']
-			)
-		);
-		$this->assert200($response);
-		$postJSON = json_decode($response->getBody());
-		$this->assertNotNull($postJSON);
-		$this->assertEquals(1, $postJSON->exists);
-		
-		// Get upload authorization for existing file with different filename
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams(array(
-				"md5" => $json['md5'],
-				"filename" => $json['filename'] . '等', // Unicode 1.1 character, to test signature generation
-				"filesize" => $size,
-				"mtime" => $json['mtime'],
-				"contentType" => $json['contentType'],
-				"charset" => $json['charset']
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: " . $json['md5']
-			)
-		);
-		$this->assert200($response);
-		$postJSON = json_decode($response->getBody());
-		$this->assertNotNull($postJSON);
-		$this->assertEquals(1, $postJSON->exists);
-		
-		return array(
-			"key" => $key,
-			"md5" => $md5,
-			"filename" => $json['filename'] . '等'
-		);
-	}
-	
-	
-	/**
-	 * @depends testAddFileExisting
-	 * @group attachments
-	 */
-	public function testGetFile($addFileData) {
-		$key = $addFileData['key'];
-		$md5 = $addFileData['md5'];
-		$filename = $addFileData['filename'];
-		
-		// Get in view mode
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key/file/view"
-		);
-		$this->assert302($response);
-		$location = $response->getHeader("Location");
-		$this->assertRegExp('/^https:\/\/[^\/]+\/[0-9]+\//', $location);
-		$filenameEncoded = rawurlencode($filename);
-		$this->assertEquals($filenameEncoded, substr($location, -1 * strlen($filenameEncoded)));
-		
-		// Get from view mode
-		$response = HTTP::get($location);
-		$this->assert200($response);
-		$this->assertEquals($md5, md5($response->getBody()));
-		
-		// Get in download mode
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key/file"
-		);
-		$this->assert302($response);
-		$location = $response->getHeader("Location");
-		
-		// Get from S3
-		$response = HTTP::get($location);
-		$this->assert200($response);
-		$this->assertEquals($md5, md5($response->getBody()));
-		
-		return array(
-			"key" => $key, 
-			"response" => $response
-		);
-	}
-	
-	
-	/**
-	 * @depends testGetFile
-	 * @group classic-sync
-	 */
-	public function testAddFilePartial($getFileData) {
-		// Get serverDateModified
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$getFileData['key']}"
-		);
-		$json = API::getJSONFromResponse($response)['data'];
-		$serverDateModified = $json['dateModified'];
-		sleep(1);
-		
-		$originalVersion = $json['version'];
-		
-		// Get a sync timestamp from before the file is updated
-		require_once 'include/sync.inc.php';
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID);
-		$lastsync = (int) $xml['timestamp'];
-		Sync::logout($sessionID);
-		
-		$oldFilename = "work/old";
-		$fileContents = $getFileData['response']->getBody();
-		file_put_contents($oldFilename, $fileContents);
-		
-		$newFilename = "work/new";
-		$patchFilename = "work/patch";
-		
-		$algorithms = array(
-			"bsdiff" => "bsdiff "
-				. escapeshellarg($oldFilename) . " "
-				. escapeshellarg($newFilename) . " "
-				. escapeshellarg($patchFilename),
-			"xdelta" => "xdelta3 -f -e -9 -S djw -s "
-				. escapeshellarg($oldFilename) . " "
-				. escapeshellarg($newFilename) . " "
-				. escapeshellarg($patchFilename),
-			"vcdiff" => "vcdiff encode "
-				. "-dictionary " . escapeshellarg($oldFilename) . " "
-				. " -target " . escapeshellarg($newFilename) . " "
-				. " -delta " . escapeshellarg($patchFilename)
-		);
-		
-		foreach ($algorithms as $algo => $cmd) {
-			clearstatcache();
-			
-			// Create random contents
-			file_put_contents($newFilename, uniqid(self::getRandomUnicodeString(), true));
-			$newHash = md5_file($newFilename);
-			
-			// Get upload authorization
-			$fileParams = array(
-				"md5" => $newHash,
-				"filename" => "test_" . $fileContents,
-				"filesize" => filesize($newFilename),
-				"mtime" => filemtime($newFilename) * 1000,
-				"contentType" => "text/plain",
-				"charset" => "utf-8"
-			);
-			$response = API::userPost(
-				self::$config['userID'],
-				"items/{$getFileData['key']}/file",
-				$this->implodeParams($fileParams),
-				array(
-					"Content-Type: application/x-www-form-urlencoded",
-					"If-Match: " . md5_file($oldFilename)
-				)
-			);
-			$this->assert200($response);
-			$json = json_decode($response->getBody());
-			$this->assertNotNull($json);
-			
-			exec($cmd, $output, $ret);
-			if ($ret != 0) {
-				echo "Warning: Error running $algo -- skipping file upload test\n";
-				continue;
-			}
-			
-			$patch = file_get_contents($patchFilename);
-			$this->assertNotEquals("", $patch);
-			
-			self::$toDelete[] = "$newHash";
-			
-			// Upload patch file
-			$response = API::userPatch(
-				self::$config['userID'],
-				"items/{$getFileData['key']}/file?algorithm=$algo&upload=" . $json->uploadKey,
-				$patch,
-				array(
-					"If-Match: " . md5_file($oldFilename)
-				)
-			);
-			$this->assert204($response);
-			
-			unlink($patchFilename);
-			rename($newFilename, $oldFilename);
-			
-			// Verify attachment item metadata
-			$response = API::userGet(
-				self::$config['userID'],
-				"items/{$getFileData['key']}"
-			);
-			$json = API::getJSONFromResponse($response)['data'];
-			$this->assertEquals($fileParams['md5'], $json['md5']);
-			$this->assertEquals($fileParams['mtime'], $json['mtime']);
-			$this->assertEquals($fileParams['contentType'], $json['contentType']);
-			$this->assertEquals($fileParams['charset'], $json['charset']);
-			
-			// Make sure version has changed
-			$this->assertNotEquals($originalVersion, $json['version']);
-			
-			// Make sure new attachment is passed via sync
-			$sessionID = Sync::login();
-			$xml = Sync::updated($sessionID, $lastsync);
-			Sync::logout($sessionID);
-			$this->assertGreaterThan(0, $xml->updated[0]->count());
-			
-			// Verify file on S3
-			$response = API::userGet(
-				self::$config['userID'],
-				"items/{$getFileData['key']}/file"
-			);
-			$this->assert302($response);
-			$location = $response->getHeader("Location");
-			
-			$response = HTTP::get($location);
-			$this->assert200($response);
-			$this->assertEquals($fileParams['md5'], md5($response->getBody()));
-			$t = $fileParams['contentType'];
-			$this->assertEquals(
-				$t . (($t && $fileParams['charset']) ? "; charset={$fileParams['charset']}" : ""),
-				$response->getHeader("Content-Type")
-			);
-		}
-	}
-	
-	
-	public function testExistingFileWithOldStyleFilename() {
-		$fileContents = self::getRandomUnicodeString();
-		$hash = md5($fileContents);
-		$filename = 'test.txt';
-		$size = strlen($fileContents);
-		
-		$parentKey = API::createItem("book", false, $this, 'key');
-		$json = API::createAttachmentItem("imported_file", [], $parentKey, $this, 'jsonData');
-		$key = $json['key'];
-		$originalVersion = $json['version'];
-		$mtime = time() * 1000;
-		$contentType = 'text/plain';
-		$charset = 'utf-8';
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType,
-				"charset" => $charset
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody());
-		$this->assertNotNull($json);
-		
-		// Upload to old-style location
-		self::$toDelete[] = "$hash/$filename";
-		self::$toDelete[] = "$hash";
-		$s3Client = Z_Tests::$AWS->createS3();
-		$s3Client->putObject([
-			'Bucket' => self::$config['s3Bucket'],
-			'Key' => $hash . '/' . $filename,
-			'Body' => $fileContents
-		]);
-		
-		// Register upload
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			"upload=" . $json->uploadKey,
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert204($response);
-		
-		// The file should be accessible on the item at the old-style location
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key/file"
-		);
-		$this->assert302($response);
-		$location = $response->getHeader("Location");
-		
-		$this->assertEquals(1, preg_match('"^https://'
-			// bucket.s3.amazonaws.com or s3.amazonaws.com/bucket
-			. '(?:[^/]+|.+' . self::$config['s3Bucket'] . ')'
-			. '/([a-f0-9]{32})/' . $filename . '\?"', $location, $matches));
-		$this->assertEquals($hash, $matches[1]);
-		
-		// Get upload authorization for the same file and filename on another item, which should
-		// result in 'exists', even though we uploaded to the old-style location
-		$parentKey = API::createItem("book", false, $this, 'key');
-		$json = API::createAttachmentItem("imported_file", [], $parentKey, $this, 'jsonData');
-		$key = $json['key'];
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType,
-				"charset" => $charset
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert200($response);
-		$postJSON = json_decode($response->getBody());
-		$this->assertNotNull($postJSON);
-		$this->assertEquals(1, $postJSON->exists);
-		
-		// Get in download mode
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key/file"
-		);
-		$this->assert302($response);
-		$location = $response->getHeader("Location");
-		$this->assertEquals(1, preg_match('"^https://'
-			// bucket.s3.amazonaws.com or s3.amazonaws.com/bucket
-			. '(?:[^/]+|.+' . self::$config['s3Bucket'] . ')'
-			. '/([a-f0-9]{32})/' . $filename . '\?"', $location, $matches));
-		$this->assertEquals($hash, $matches[1]);
-		
-		// Get from S3
-		$response = HTTP::get($location);
-		$this->assert200($response);
-		$this->assertEquals($fileContents, $response->getBody());
-		$this->assertEquals($contentType . '; charset=' . $charset, $response->getHeader('Content-Type'));
-		
-		// Get upload authorization for the same file and different filename on another item,
-		// which should result in 'exists' and a copy of the file to the hash-only location
-		$parentKey = API::createItem("book", false, $this, 'key');
-		$json = API::createAttachmentItem("imported_file", [], $parentKey, $this, 'jsonData');
-		$key = $json['key'];
-		// Also use a different content type
-		$contentType = 'application/x-custom';
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => "test2.txt",
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert200($response);
-		$postJSON = json_decode($response->getBody());
-		$this->assertNotNull($postJSON);
-		$this->assertEquals(1, $postJSON->exists);
-		
-		// Get in download mode
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key/file"
-		);
-		$this->assert302($response);
-		$location = $response->getHeader("Location");
-		$this->assertEquals(1, preg_match('"^https://'
-			// bucket.s3.amazonaws.com or s3.amazonaws.com/bucket
-			. '(?:[^/]+|.+' . self::$config['s3Bucket'] . ')'
-			. '/([a-f0-9]{32})\?"', $location, $matches));
-		$this->assertEquals($hash, $matches[1]);
-		
-		// Get from S3
-		$response = HTTP::get($location);
-		$this->assert200($response);
-		$this->assertEquals($fileContents, $response->getBody());
-		$this->assertEquals($contentType, $response->getHeader('Content-Type'));
-	}
-	
-	/**
-	 * @group classic-sync
-	 */
-	public function testAddFileClientV4() {
-		API::userClear(self::$config['userID']);
-		
-		$fileContentType = "text/html";
-		$fileCharset = "utf-8";
-		
-		$auth = array(
-			'username' => self::$config['username'],
-			'password' => self::$config['password']
-		);
-		
-		// Get last storage sync
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync?auth=1",
-			array(),
-			$auth
-		);
-		$this->assert404($response);
-		
-		$json = API::createAttachmentItem("imported_file", [], false, $this, 'jsonData');
-		$originalVersion = $json['version'];
-		$json['contentType'] = $fileContentType;
-		$json['charset'] = $fileCharset;
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$json['key']}",
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		$originalVersion = $response->getHeader("Last-Modified-Version");
-		
-		// Get a sync timestamp from before the file is updated
-		sleep(1);
-		require_once 'include/sync.inc.php';
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID);
-		$lastsync = (int) $xml['timestamp'];
-		Sync::logout($sessionID);
-		
-		// Get file info
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$json['key']}/file?auth=1&iskey=1&version=1&info=1",
-			array(),
-			$auth
-		);
-		$this->assert404($response);
-		
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = filesize($file);
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$json['key']}/file?auth=1&iskey=1&version=1",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/xml", $response);
-		$xml = new SimpleXMLElement($response->getBody());
-		
-		self::$toDelete[] = "$hash";
-		
-		$boundary = "---------------------------" . rand();
-		$postData = "";
-		foreach ($xml->params->children() as $key => $val) {
-			$postData .= "--" . $boundary . "\r\nContent-Disposition: form-data; "
-				. "name=\"$key\"\r\n\r\n$val\r\n";
-		}
-		$postData .= "--" . $boundary . "\r\nContent-Disposition: form-data; "
-				. "name=\"file\"\r\n\r\n" . $fileContents . "\r\n";
-		$postData .= "--" . $boundary . "--";
-		
-		// Upload to S3
-		$response = HTTP::post(
-			(string) $xml->url,
-			$postData,
-			array(
-				"Content-Type: multipart/form-data; boundary=" . $boundary
-			)
-		);
-		$this->assert201($response);
-		
-		//
-		// Register upload
-		//
-		
-		// Invalid upload key
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$json['key']}/file?auth=1&iskey=1&version=1",
-			"update=invalidUploadKey&mtime=" . $mtime,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert400($response);
-		
-		// No mtime
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$json['key']}/file?auth=1&iskey=1&version=1",
-			"update=" . $xml->key,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert500($response);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$json['key']}/file?auth=1&iskey=1&version=1",
-			"update=" . $xml->key . "&mtime=" . $mtime,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert204($response);
-		
-		// Verify attachment item metadata
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$json['key']}"
-		);
-		$json = API::getJSONFromResponse($response)['data'];
-		
-		$this->assertEquals($hash, $json['md5']);
-		$this->assertEquals($filename, $json['filename']);
-		$this->assertEquals($mtime, $json['mtime']);
-		
-		// Make sure attachment item wasn't updated (or else the client
-		// will get a conflict when it tries to update the metadata)
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, $lastsync);
-		Sync::logout($sessionID);
-		$this->assertEquals(0, $xml->updated[0]->count());
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync?auth=1",
-			array(),
-			array(
-				'username' => self::$config['username'],
-				'password' => self::$config['password']
-			)
-		);
-		$this->assert200($response);
-		$mtime = $response->getBody();
-		$this->assertRegExp('/^[0-9]{10}$/', $mtime);
-		
-		// File exists
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$json['key']}/file?auth=1&iskey=1&version=1",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime + 1000
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/xml", $response);
-		$this->assertEquals("<exists/>", $response->getBody());
-		
-		// File exists with different filename
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$json['key']}/file?auth=1&iskey=1&version=1",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename . '等', // Unicode 1.1 character, to test signature generation
-				"filesize" => $size,
-				"mtime" => $mtime + 1000
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/xml", $response);
-		$this->assertEquals("<exists/>", $response->getBody());
-		
-		// Make sure attachment item still wasn't updated
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, $lastsync);
-		$this->assertEquals(0, $xml->updated[0]->count());
-		
-		// Get attachment
-		$xml = Sync::updated($sessionID, 2);
-		$this->assertEquals(1, $xml->updated[0]->items->count());
-		$itemXML = $xml->xpath("//updated/items/item[@key='" . $json['key'] . "']")[0];
-		$this->assertEquals($fileContentType, (string) $itemXML['mimeType']);
-		$this->assertEquals($fileCharset, (string) $itemXML['charset']);
-		$this->assertEquals($hash, (string) $itemXML['storageHash']);
-		$this->assertEquals($mtime + 1000, (string) $itemXML['storageModTime']);
-		
-		Sync::logout($sessionID);
-	}
-	
-	/**
-	 * @group classic-sync
-	 */
-	public function testAddFileClientV4Zip() {
-		API::userClear(self::$config['userID']);
-		
-		$auth = array(
-			'username' => self::$config['username'],
-			'password' => self::$config['password']
-		);
-		
-		// Get last storage sync
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync?auth=1",
-			array(),
-			$auth
-		);
-		$this->assert404($response);
-		
-		$json = API::createItem("book", false, $this, 'jsonData');
-		$key = $json['key'];
-		
-		$fileContentType = "text/html";
-		$fileCharset = "UTF-8";
-		$fileFilename = "file.html";
-		$fileModtime = time();
-		
-		$json = API::createAttachmentItem("imported_url", [], $key, $this, 'jsonData');
-		$key = $json['key'];
-		$version = $json['version'];
-		$json['contentType'] = $fileContentType;
-		$json['charset'] = $fileCharset;
-		$json['filename'] = $fileFilename;
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key",
-			json_encode($json),
-			array(
-				"Content-Type: application/json"
-			)
-		);
-		$this->assert204($response);
-		
-		// Get a sync timestamp from before the file is updated
-		sleep(1);
-		require_once 'include/sync.inc.php';
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID);
-		$lastsync = (int) $xml['timestamp'];
-		Sync::logout($sessionID);
-		
-		// Get file info
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$json['key']}/file?auth=1&iskey=1&version=1&info=1",
-			array(),
-			$auth
-		);
-		$this->assert404($response);
-		
-		$zip = new \ZipArchive();
-		$file = "work/$key.zip";
-		
-		if ($zip->open($file, \ZIPARCHIVE::CREATE) !== TRUE) {
-			throw new Exception("Cannot open ZIP file");
-		}
-		
-		$zip->addFromString($fileFilename, self::getRandomUnicodeString());
-		$zip->addFromString("file.css", self::getRandomUnicodeString());
-		$zip->close();
-		
-		$hash = md5_file($file);
-		$filename = $key . ".zip";
-		$size = filesize($file);
-		$fileContents = file_get_contents($file);
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$json['key']}/file?auth=1&iskey=1&version=1",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $fileModtime,
-				"zip" => 1
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/xml", $response);
-		$xml = new SimpleXMLElement($response->getBody());
-		
-		self::$toDelete[] = "$hash";
-		
-		$boundary = "---------------------------" . rand();
-		$postData = "";
-		foreach ($xml->params->children() as $key => $val) {
-			$postData .= "--" . $boundary . "\r\nContent-Disposition: form-data; "
-				. "name=\"$key\"\r\n\r\n$val\r\n";
-		}
-		$postData .= "--" . $boundary . "\r\nContent-Disposition: form-data; "
-				. "name=\"file\"\r\n\r\n" . $fileContents . "\r\n";
-		$postData .= "--" . $boundary . "--";
-		
-		// Upload to S3
-		$response = HTTP::post(
-			(string) $xml->url,
-			$postData,
-			array(
-				"Content-Type: multipart/form-data; boundary=" . $boundary
-			)
-		);
-		$this->assert201($response);
-		
-		//
-		// Register upload
-		//
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$json['key']}/file?auth=1&iskey=1&version=1",
-			"update=" . $xml->key . "&mtime=" . $fileModtime,
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert204($response);
-		
-		// Verify attachment item metadata
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$json['key']}"
-		);
-		$json = API::getJSONFromResponse($response)['data'];
-		
-		$this->assertEquals($hash, $json['md5']);
-		$this->assertEquals($fileFilename, $json['filename']);
-		$this->assertEquals($fileModtime, $json['mtime']);
-		
-		// Make sure attachment item wasn't updated (or else the client
-		// will get a conflict when it tries to update the metadata)
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, $lastsync);
-		Sync::logout($sessionID);
-		$this->assertEquals(0, $xml->updated[0]->count());
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync?auth=1",
-			array(),
-			array(
-				'username' => self::$config['username'],
-				'password' => self::$config['password']
-			)
-		);
-		$this->assert200($response);
-		$mtime = $response->getBody();
-		$this->assertRegExp('/^[0-9]{10}$/', $mtime);
-		
-		// File exists
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/{$json['key']}/file?auth=1&iskey=1&version=1",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $fileModtime + 1000,
-				"zip" => 1
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded"
-			),
-			$auth
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/xml", $response);
-		$this->assertEquals("<exists/>", $response->getBody());
-		
-		// Make sure attachment item still wasn't updated
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, $lastsync);
-		Sync::logout($sessionID);
-		$this->assertEquals(0, $xml->updated[0]->count());
-	}
-	
-	/**
-	 * @group classic-sync
-	 */
-	public function testAddFileClientV5() {
-		API::userClear(self::$config['userID']);
-		
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		$contentType = "text/html";
-		$charset = "utf-8";
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = filesize($file);
-		
-		// Get last storage sync
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync"
-		);
-		$this->assert404($response);
-		
-		$json = API::createAttachmentItem("imported_file", [
-			'contentType' => $contentType,
-			'charset' => $charset
-		], false, $this, 'jsonData');
-		$key = $json['key'];
-		$originalVersion = $json['version'];
-		
-		// Get a sync timestamp from before the file is updated
-		sleep(1);
-		require_once 'include/sync.inc.php';
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID);
-		$lastsync = (int) $xml['timestamp'];
-		Sync::logout($sessionID);
-		
-		// File shouldn't exist
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key/file"
-		);
-		$this->assert404($response);
-		
-		//
-		// Get upload authorization
-		//
-		
-		// Require If-Match/If-None-Match
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime,
-				"filename" => $filename,
-				"filesize" => $size
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded"
-			]
-		);
-		$this->assert428($response, "If-Match/If-None-Match header not provided");
-		
-		// Get authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime,
-				"filename" => $filename,
-				"filesize" => $size
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		self::$toDelete[] = "$hash";
-		
-		//
-		// Upload to S3
-		//
-		$response = HTTP::post(
-			$json['url'],
-			$json['prefix'] . $fileContents . $json['suffix'],
-			[
-				"Content-Type: {$json['contentType']}"
-			]
-		);
-		$this->assert201($response);
-		
-		//
-		// Register upload
-		//
-		
-		// Require If-Match/If-None-Match
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			"upload=" . $json['uploadKey'],
-			[
-				"Content-Type: application/x-www-form-urlencoded"
-			]
-		);
-		$this->assert428($response, "If-Match/If-None-Match header not provided");
-		
-		// Invalid upload key
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			"upload=invalidUploadKey",
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert400($response);
-		
-		// If-Match shouldn't match unregistered file
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			"upload=" . $json['uploadKey'],
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: $hash"
-			]
-		);
-		$this->assert412($response);
-		$this->assertNull($response->getHeader("Last-Modified-Version"));
-		
-		// Successful registration
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			"upload=" . $json['uploadKey'],
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert204($response);
-		$newVersion = $response->getHeader('Last-Modified-Version');
-		$this->assertGreaterThan($originalVersion, $newVersion);
-		
-		// Verify attachment item metadata
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key"
-		);
-		$json = API::getJSONFromResponse($response)['data'];
-		$this->assertEquals($hash, $json['md5']);
-		$this->assertEquals($mtime, $json['mtime']);
-		$this->assertEquals($filename, $json['filename']);
-		$this->assertEquals($contentType, $json['contentType']);
-		$this->assertEquals($charset, $json['charset']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync"
-		);
-		$this->assert200($response);
-		$this->assertRegExp('/^[0-9]{10}$/', $response->getBody());
-		
-		//
-		// Update file
-		//
-		
-		// Conflict for If-None-Match when file exists
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime + 1000,
-				"filename" => $filename,
-				"filesize" => $size
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert412($response, "If-None-Match: * set but file exists");
-		$this->assertNotNull($response->getHeader("Last-Modified-Version"));
-		
-		// Conflict for If-Match when existing file differs
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime + 1000,
-				"filename" => $filename,
-				"filesize" => $size
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: " . md5("invalid")
-			]
-		);
-		$this->assert412($response, "ETag does not match current version of file");
-		$this->assertNotNull($response->getHeader("Last-Modified-Version"));
-		
-		// File exists
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime + 1000,
-				"filename" => $filename,
-				"filesize" => $size
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: $hash"
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertArrayHasKey("exists", $json);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($newVersion, $version);
-		$newVersion = $version;
-		
-		// File exists with different filename
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime + 1000,
-				"filename" => $filename . '等', // Unicode 1.1 character, to test signature generation
-				"filesize" => $size,
-				"contentType" => $contentType,
-				"charset" => $charset
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: $hash"
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertArrayHasKey("exists", $json);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($newVersion, $version);
-		
-		// Get attachment via classic sync
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, 2);
-		$this->assertEquals(1, $xml->updated[0]->items->count());
-		$itemXML = $xml->xpath("//updated/items/item[@key='$key']")[0];
-		$this->assertEquals($contentType, (string) $itemXML['mimeType']);
-		$this->assertEquals($charset, (string) $itemXML['charset']);
-		$this->assertEquals($hash, (string) $itemXML['storageHash']);
-		$this->assertEquals($mtime + 1000, (string) $itemXML['storageModTime']);
-		Sync::logout($sessionID);
-	}
-	
-	/**
-	 * @group classic-sync
-	 */
-	public function testAddFileClientV5Zip() {
-		API::userClear(self::$config['userID']);
-		
-		$fileContents = self::getRandomUnicodeString();
-		$contentType = "text/html";
-		$charset = "utf-8";
-		$filename = "file.html";
-		$mtime = time();
-		$hash = md5($fileContents);
-		
-		
-		// Get last storage sync
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync"
-		);
-		$this->assert404($response);
-		
-		$json = API::createItem("book", false, $this, 'jsonData');
-		$key = $json['key'];
-		
-		$json = API::createAttachmentItem("imported_url", [
-			'contentType' => $contentType,
-			'charset' => $charset
-		], $key, $this, 'jsonData');
-		$key = $json['key'];
-		$originalVersion = $json['version'];
-		
-		// Create ZIP file
-		$zip = new \ZipArchive();
-		$file = "work/$key.zip";
-		if ($zip->open($file, \ZIPARCHIVE::CREATE) !== TRUE) {
-			throw new Exception("Cannot open ZIP file");
-		}
-		$zip->addFromString($filename, $fileContents);
-		$zip->addFromString("file.css", self::getRandomUnicodeString());
-		$zip->close();
-		$zipHash = md5_file($file);
-		$zipFilename = $key . ".zip";
-		$zipSize = filesize($file);
-		$zipFileContents = file_get_contents($file);
-		
-		// Get a sync timestamp from before the file is updated
-		sleep(1);
-		require_once 'include/sync.inc.php';
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID);
-		$lastsync = (int) $xml['timestamp'];
-		Sync::logout($sessionID);
-		
-		//
-		// Get upload authorization
-		//
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime,
-				"filename" => $filename,
-				"filesize" => $zipSize,
-				"zipMD5" => $zipHash,
-				"zipFilename" => $zipFilename
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		self::$toDelete[] = "$zipHash";
-		
-		// Upload to S3
-		$response = HTTP::post(
-			$json['url'],
-			$json['prefix'] . $zipFileContents . $json['suffix'],
-			[
-				"Content-Type: {$json['contentType']}"
-			]
-		);
-		$this->assert201($response);
-		
-		//
-		// Register upload
-		//
-		
-		// If-Match with file hash shouldn't match unregistered file
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			"upload=" . $json['uploadKey'],
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: $hash"
-			]
-		);
-		$this->assert412($response);
-		
-		// If-Match with ZIP hash shouldn't match unregistered file
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			"upload=" . $json['uploadKey'],
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: $zipHash"
-			]
-		);
-		$this->assert412($response);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			"upload=" . $json['uploadKey'],
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert204($response);
-		$newVersion = $response->getHeader("Last-Modified-Version");
-		
-		// Verify attachment item metadata
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key"
-		);
-		$json = API::getJSONFromResponse($response)['data'];
-		$this->assertEquals($hash, $json['md5']);
-		$this->assertEquals($mtime, $json['mtime']);
-		$this->assertEquals($filename, $json['filename']);
-		$this->assertEquals($contentType, $json['contentType']);
-		$this->assertEquals($charset, $json['charset']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync"
-		);
-		$this->assert200($response);
-		$this->assertRegExp('/^[0-9]{10}$/', $response->getBody());
-		
-		// File exists
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime + 1000,
-				"filename" => $filename,
-				"filesize" => $zipSize,
-				"zip" => 1,
-				"zipMD5" => $zipHash,
-				"zipFilename" => $zipFilename
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-Match: $hash"
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertArrayHasKey("exists", $json);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($newVersion, $version);
-		
-		// Get attachment via classic sync
-		$sessionID = Sync::login();
-		$xml = Sync::updated($sessionID, 2);
-		$this->assertEquals(1, $xml->updated[0]->items->count());
-		$itemXML = $xml->xpath("//updated/items/item[@key='$key']")[0];
-		$this->assertEquals($contentType, (string) $itemXML['mimeType']);
-		$this->assertEquals($charset, (string) $itemXML['charset']);
-		$this->assertEquals($hash, (string) $itemXML['storageHash']);
-		$this->assertEquals($mtime + 1000, (string) $itemXML['storageModTime']);
-		Sync::logout($sessionID);
-	}
-	
-	
-	public function testClientV5ShouldRejectFileSizeMismatch() {
-		API::userClear(self::$config['userID']);
-		
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		$contentType = "text/plain";
-		$charset = "utf-8";
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = 0;
-		
-		$json = API::createAttachmentItem("imported_file", [
-			'contentType' => $contentType,
-			'charset' => $charset
-		], false, $this, 'jsonData');
-		$key = $json['key'];
-		$originalVersion = $json['version'];
-		
-		// Get authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime,
-				"filename" => $filename,
-				"filesize" => $size
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		self::$toDelete[] = "$hash";
-		
-		// Try to upload to S3, which should fail
-		$response = HTTP::post(
-			$json['url'],
-			$json['prefix'] . $fileContents . $json['suffix'],
-			[
-				"Content-Type: {$json['contentType']}"
-			]
-		);
-		$this->assert400($response);
-		$this->assertContains(
-			"Your proposed upload exceeds the maximum allowed size", $response->getBody()
-		);
-	}
-	
-	
-	public function testClientV5ShouldReturn404GettingAuthorizationForMissingFile() {
-		// Get authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/UP24VFQR/file",
-			$this->implodeParams([
-				"md5" => md5('qzpqBjLddCc6UhfX'),
-				"mtime" => 1477002989206,
-				"filename" => 'test.pdf',
-				"filesize" => 12345
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert404($response);
-	}
-	
-	
-	public function testAddFileLinkedAttachment() {
-		$key = API::createAttachmentItem("linked_file", [], false, $this, 'key');
-		
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = filesize($file);
-		$contentType = "text/plain";
-		$charset = "utf-8";
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams(array(
-				"md5" => $hash,
-				"filename" => $filename,
-				"filesize" => $size,
-				"mtime" => $mtime,
-				"contentType" => $contentType,
-				"charset" => $charset
-			)),
-			array(
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			)
-		);
-		$this->assert400($response);
-	}
-	
-	
-	public function test_updating_attachment_hash_should_clear_associated_storage_file() {
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		$contentType = "text/html";
-		$charset = "utf-8";
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = filesize($file);
-		
-		$json = API::createAttachmentItem("imported_file", [
-			'contentType' => $contentType,
-			'charset' => $charset
-		], false, $this, 'jsonData');
-		$itemKey = $json['key'];
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$itemKey/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime,
-				"filename" => $filename,
-				"filesize" => $size
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		self::$toDelete[] = "$hash";
-		
-		// Upload to S3
-		$response = HTTP::post(
-			$json['url'],
-			$json['prefix'] . $fileContents . $json['suffix'],
-			[
-				"Content-Type: {$json['contentType']}"
-			]
-		);
-		$this->assert201($response);
-		
-		// Register upload
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$itemKey/file",
-			"upload=" . $json['uploadKey'],
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert204($response);
-		$newVersion = $response->getHeader('Last-Modified-Version');
-		
-		$filename = "test.pdf";
-		$mtime = time();
-		$hash = md5(uniqid());
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$itemKey",
-			json_encode([
-				"filename" => $filename,
-				"mtime" => $mtime,
-				"md5" => $hash,
-			]),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $newVersion"
-			]
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$itemKey/file"
-		);
-		$this->assert404($response);
-	}
-	
-	
-	public function test_updating_compressed_attachment_hash_should_clear_associated_storage_file() {
-		// Create initial file
-		$fileContents = self::getRandomUnicodeString();
-		$contentType = "text/html";
-		$charset = "utf-8";
-		$filename = "file.html";
-		$mtime = time();
-		$hash = md5($fileContents);
-		
-		$json = API::createAttachmentItem("imported_file", [
-			'contentType' => $contentType,
-			'charset' => $charset
-		], false, $this, 'jsonData');
-		$itemKey = $json['key'];
-		
-		// Create initial ZIP file
-		$zip = new \ZipArchive();
-		$file = "work/$itemKey.zip";
-		if ($zip->open($file, \ZIPARCHIVE::CREATE) !== TRUE) {
-			throw new Exception("Cannot open ZIP file");
-		}
-		$zip->addFromString($filename, $fileContents);
-		$zip->addFromString("file.css", self::getRandomUnicodeString());
-		$zip->close();
-		$zipHash = md5_file($file);
-		$zipFilename = $itemKey . ".zip";
-		$zipSize = filesize($file);
-		$zipFileContents = file_get_contents($file);
-		
-		// Get upload authorization
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$itemKey/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime,
-				"filename" => $filename,
-				"filesize" => $zipSize,
-				"zipMD5" => $zipHash,
-				"zipFilename" => $zipFilename
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		self::$toDelete[] = "$zipHash";
-		
-		// Upload to S3
-		$response = HTTP::post(
-			$json['url'],
-			$json['prefix'] . $zipFileContents . $json['suffix'],
-			[
-				"Content-Type: {$json['contentType']}"
-			]
-		);
-		$this->assert201($response);
-		
-		// Register upload
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$itemKey/file",
-			"upload=" . $json['uploadKey'],
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert204($response);
-		$newVersion = $response->getHeader('Last-Modified-Version');
-		
-		// Set new attachment file info
-		$hash = md5(uniqid());
-		$mtime = time();
-		$zipHash = md5(uniqid());
-		$zipSize++;
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$itemKey",
-			json_encode([
-				"md5" => $hash,
-				"mtime" => $mtime,
-				"filename" => $filename
-			]),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $newVersion"
-			]
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$itemKey/file"
-		);
-		$this->assert404($response);
-	}
-	
-	
-	// TODO: Reject for keys not owned by user, even if public library
-	public function testLastStorageSyncNoAuthorization() {
-		API::useAPIKey(false);
-		$response = API::userGet(
-			self::$config['userID'],
-			"laststoragesync"
-		);
-		$this->assert401($response);
-	}
-	
-	
-	private function implodeParams($params, $exclude=array()) {
-		$parts = array();
-		foreach ($params as $key => $val) {
-			if (in_array($key, $exclude)) {
-				continue;
-			}
-			$parts[] = $key . "=" . urlencode($val);
-		}
-		return implode("&", $parts);
-	}
-	
-	
-	private function getRandomUnicodeString() {
-		return "Âéìøü 这是一个测试。 " . uniqid();
-	}
-}
diff --git a/tests/remote/tests/API/3/FullTextTest.php b/tests/remote/tests/API/3/FullTextTest.php
deleted file mode 100644
index e088ab52..00000000
--- a/tests/remote/tests/API/3/FullTextTest.php
+++ /dev/null
@@ -1,447 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-/**
- * @group fulltext
- */
-class FullTextTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testVersionsAnonymous() {
-		API::useAPIKey(false);
-		$response = API::userGet(
-			self::$config['userID'],
-			"fulltext"
-		);
-		$this->assert403($response);
-	}
-	
-	
-	public function testContentAnonymous() {
-		API::useAPIKey(false);
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/AAAAAAAA/fulltext"
-		);
-		$this->assert403($response);
-	}
-	
-	public function testSetItemContent() {
-		$key = API::createItem("book", false, $this, 'key');
-		$attachmentKey = API::createAttachmentItem("imported_url", [], $key, $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$attachmentKey/fulltext"
-		);
-		$this->assert404($response);
-		$this->assertNull($response->getHeader("Last-Modified-Version"));
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$content = "Here is some full-text content";
-		$pages = 50;
-		
-		// No Content-Type
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$attachmentKey/fulltext",
-			$content
-		);
-		$this->assert400($response, "Content-Type must be application/json");
-		
-		// Store content
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$attachmentKey/fulltext",
-			json_encode([
-				"content" => $content,
-				"indexedPages" => $pages,
-				"totalPages" => $pages,
-				"invalidParam" => "shouldBeIgnored"
-			]),
-			array("Content-Type: application/json")
-		);
-		
-		$this->assert204($response);
-		$contentVersion = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($libraryVersion, $contentVersion);
-		
-		// Retrieve it
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$attachmentKey/fulltext"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertEquals($content, $json['content']);
-		$this->assertArrayHasKey('indexedPages', $json);
-		$this->assertArrayHasKey('totalPages', $json);
-		$this->assertEquals($pages, $json['indexedPages']);
-		$this->assertEquals($pages, $json['totalPages']);
-		$this->assertArrayNotHasKey("indexedChars", $json);
-		$this->assertArrayNotHasKey("invalidParam", $json);
-		$this->assertEquals($contentVersion, $response->getHeader("Last-Modified-Version"));
-	}
-	
-	
-	public function testSetItemContentMultiple() {
-		$key = API::createItem("book", false, $this, 'key');
-		$attachmentKey1 = API::createAttachmentItem("imported_url", [], $key, $this, 'key');
-		$attachmentKey2 = API::createAttachmentItem("imported_url", [], $key, $this, 'key');
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$json = [
-			[
-				"key" => $attachmentKey1,
-				"content" => "Here is some full-text content",
-				"indexedPages" => 50,
-				"totalPages" => 50,
-				"invalidParam" => "shouldBeIgnored"
-			],
-			[
-				"content" => "This is missing a key and should be skipped",
-				"indexedPages" => 20,
-				"totalPages" => 40
-			],
-			[
-				"key" => $attachmentKey2,
-				"content" => "Here is some more full-text content",
-				"indexedPages" => 20,
-				"totalPages" => 40
-			]
-		];
-		
-		// No Content-Type
-		$response = API::userPost(
-			self::$config['userID'],
-			"fulltext",
-			json_encode($json),
-			[
-				"If-Unmodified-Since-Version: $libraryVersion"
-			]
-		);
-		$this->assert400($response, "Content-Type must be application/json");
-		
-		// No If-Unmodified-Since-Version
-		$response = API::userPost(
-			self::$config['userID'],
-			"fulltext",
-			json_encode($json),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert428($response, "If-Unmodified-Since-Version not provided");
-		
-		// Store content
-		$response = API::userPost(
-			self::$config['userID'],
-			"fulltext",
-			json_encode($json),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $libraryVersion"
-			]
-		);
-		
-		$this->assert200($response);
-		$this->assert200ForObject($response, false, 0);
-		$this->assert400ForObject($response, false, 1);
-		$this->assert200ForObject($response, false, 2);
-		$newLibraryVersion = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($libraryVersion, $newLibraryVersion);
-		$libraryVersion = $newLibraryVersion;
-		
-		$originalJSON = $json;
-		
-		// Retrieve content
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$attachmentKey1/fulltext"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertEquals($originalJSON[0]['content'], $json['content']);
-		$this->assertEquals($originalJSON[0]['indexedPages'], $json['indexedPages']);
-		$this->assertEquals($originalJSON[0]['totalPages'], $json['totalPages']);
-		$this->assertArrayNotHasKey("indexedChars", $json);
-		$this->assertArrayNotHasKey("invalidParam", $json);
-		$this->assertEquals($libraryVersion, $response->getHeader("Last-Modified-Version"));
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$attachmentKey2/fulltext"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertEquals($originalJSON[2]['content'], $json['content']);
-		$this->assertEquals($originalJSON[2]['indexedPages'], $json['indexedPages']);
-		$this->assertEquals($originalJSON[2]['totalPages'], $json['totalPages']);
-		$this->assertArrayNotHasKey("indexedChars", $json);
-		$this->assertArrayNotHasKey("invalidParam", $json);
-		$this->assertEquals($libraryVersion, $response->getHeader("Last-Modified-Version"));
-	}
-	
-	
-	public function testModifyAttachmentWithFulltext() {
-		$key = API::createItem("book", false, $this, 'key');
-		$json = API::createAttachmentItem("imported_url", [], $key, $this, 'jsonData');
-		$attachmentKey = $json['key'];
-		$content = "Here is some full-text content";
-		$pages = 50;
-		
-		// Store content
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$attachmentKey/fulltext",
-			json_encode([
-				"content" => $content,
-				"indexedPages" => $pages,
-				"totalPages" => $pages
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		
-		$json['title'] = "This is a new attachment title";
-		$json['contentType'] = 'text/plain';
-		
-		// Modify attachment item
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$attachmentKey",
-			json_encode($json),
-			array("If-Unmodified-Since-Version: " . $json['version'])
-		);
-		$this->assert204($response);
-	}
-	
-	
-	public function testSinceContent() {
-		self::_testSinceContent('since');
-		self::_testSinceContent('newer');
-	}
-	
-	
-	public function testSearchItemContent() {
-		$key = API::createItem("book", false, $this, 'key');
-		$json = API::createAttachmentItem("imported_url", [], $key, $this, 'jsonData');
-		$attachmentKey = $json['key'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$attachmentKey/fulltext"
-		);
-		$this->assert404($response);
-		
-		$content = "Here is some unique full-text content";
-		$pages = 50;
-		
-		// Store content
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$attachmentKey/fulltext",
-			json_encode([
-				"content" => $content,
-				"indexedPages" => $pages,
-				"totalPages" => $pages
-			]),
-			array("Content-Type: application/json")
-		);
-		
-		$this->assert204($response);
-		
-		// Wait for indexing via Lambda
-		sleep(3);
-		
-		// Search for a word
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=unique&qmode=everything&format=keys"
-			. "&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertEquals($json['key'], trim($response->getBody()));
-		
-		// Search for a phrase
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=unique%20full-text&qmode=everything&format=keys"
-			. "&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertEquals($attachmentKey, trim($response->getBody()));
-		
-		// Search for nonexistent word
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=nothing&qmode=everything&format=keys"
-			. "&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertEquals("", trim($response->getBody()));
-	}
-	
-	
-	public function testDeleteItemContent() {
-		$key = API::createItem("book", false, $this, 'key');
-		$attachmentKey = API::createAttachmentItem("imported_file", [], $key, $this, 'key');
-		
-		$content = "Ыюм мютат дэбетиз конвынёры эю, ку мэль жкрипта трактатоз.\nПро ут чтэт эрепюят граэкйж, дуо нэ выро рыкючабо пырикюлёз.";
-		
-		// Store content
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$attachmentKey/fulltext",
-			json_encode([
-				"content" => $content,
-				"indexedPages" => 50
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		$contentVersion = $response->getHeader("Last-Modified-Version");
-		
-		// Retrieve it
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$attachmentKey/fulltext"
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertEquals($content, $json['content']);
-		$this->assertEquals(50, $json['indexedPages']);
-		
-		// Set to empty string
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$attachmentKey/fulltext",
-			json_encode([
-				"content" => ""
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		$this->assertGreaterThan($contentVersion, $response->getHeader("Last-Modified-Version"));
-		
-		// Make sure it's gone
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$attachmentKey/fulltext"
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertEquals("", $json['content']);
-		$this->assertArrayNotHasKey("indexedPages", $json);
-	}
-	
-	
-	private function _testSinceContent($param) {
-		API::userClear(self::$config['userID']);
-		
-		// Store content for one item
-		$key = API::createItem("book", false, $this, 'key');
-		$json = API::createAttachmentItem("imported_url", [], $key, $this, 'jsonData');
-		$key1 = $json['key'];
-		
-		$content = "Here is some full-text content";
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key1/fulltext",
-			json_encode([
-				"content" => $content
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		$contentVersion1 = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan(0, $contentVersion1);
-		
-		// And another
-		$key = API::createItem("book", false, $this, 'key');
-		$json = API::createAttachmentItem("imported_url", [], $key, $this, 'jsonData');
-		$key2 = $json['key'];
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key2/fulltext",
-			json_encode([
-				"content" => $content
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		$contentVersion2 = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan(0, $contentVersion2);
-		
-		// Get newer one
-		$response = API::userGet(
-			self::$config['userID'],
-			"fulltext?$param=$contentVersion1"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$this->assertEquals($contentVersion2, $response->getHeader("Last-Modified-Version"));
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(1, $json);
-		$this->assertArrayHasKey($key2, $json);
-		$this->assertEquals($contentVersion2, $json[$key2]);
-		
-		// Get both with since=0
-		$response = API::userGet(
-			self::$config['userID'],
-			"fulltext?$param=0"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json);
-		$this->assertArrayHasKey($key1, $json);
-		$this->assertEquals($contentVersion1, $json[$key1]);
-		$this->assertArrayHasKey($key1, $json);
-		$this->assertEquals($contentVersion2, $json[$key2]);
-	}
-}
diff --git a/tests/remote/tests/API/3/GeneralTest.php b/tests/remote/tests/API/3/GeneralTest.php
deleted file mode 100644
index 27376623..00000000
--- a/tests/remote/tests/API/3/GeneralTest.php
+++ /dev/null
@@ -1,93 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class GeneralTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testZoteroWriteToken() {
-		$json = API::getItemTemplate("book");
-		
-		$token = md5(uniqid());
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			array(
-				"Content-Type: application/json",
-				"Zotero-Write-Token: $token"
-			)
-		);
-		$this->assert200ForObject($response);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			array(
-				"Content-Type: application/json",
-				"Zotero-Write-Token: $token"
-			)
-		);
-		$this->assert412($response);
-	}
-	
-	
-	public function testInvalidCharacters() {
-		$data = array(
-			'title' => "A" . chr(0) . "A",
-			'creators' => array(
-				array(
-					'creatorType' => "author",
-					'name' => "B" . chr(1) . "B"
-				)
-			),
-			'tags' => array(
-				array(
-					'tag' => "C" . chr(2) . "C"
-				)
-			)
-		);
-		$json = API::createItem("book", $data, $this, 'jsonData');
-		$this->assertEquals("AA", $json['title']);
-		$this->assertEquals("BB", $json['creators'][0]['name']);
-		$this->assertEquals("CC", $json['tags'][0]['tag']);
-	}
-}
diff --git a/tests/remote/tests/API/3/GroupTest.php b/tests/remote/tests/API/3/GroupTest.php
deleted file mode 100644
index 50963d7f..00000000
--- a/tests/remote/tests/API/3/GroupTest.php
+++ /dev/null
@@ -1,277 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API, SimpleXMLElement;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class GroupTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	
-	/**
-	 * Changing a group's metadata should change its version
-	 */
-	public function testUpdateMetadataJSON() {
-		$response = API::userGet(
-			self::$config['userID'],
-			"groups"
-		);
-		$this->assert200($response);
-		
-		// Get group API URI and version
-		$json = API::getJSONFromResponse($response)[0];
-		$groupID = $json['id'];
-		$url = $json['links']['self']['href'];
-		$url = str_replace(self::$config['apiURLPrefix'], '', $url);
-		$version = $json['version'];
-		
-		// Make sure format=versions returns the same version
-		$response = API::userGet(
-			self::$config['userID'],
-			"groups?format=versions&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertEquals($version, json_decode($response->getBody())->$groupID);
-		
-		// Update group metadata
-		$xml = new SimpleXMLElement("<group/>");
-		foreach ($json['data'] as $key => $val) {
-			switch ($key) {
-			case 'id':
-			case 'version':
-			case 'members':
-				continue;
-			
-			case 'name':
-				$name = "My Test Group " . uniqid();
-				$xml['name'] = $name;
-				break;
-			
-			case 'description':
-				$description = "This is a test description " . uniqid();
-				$xml->$key = $description;
-				break;
-			
-			case 'url':
-				$urlField = "http://example.com/" . uniqid();
-				$xml->$key = $urlField;
-				break;
-			
-			default:
-				$xml[$key] = $val;
-			}
-		}
-		$xml = trim(preg_replace('/^<\?xml.+\n/', "", $xml->asXML()));
-		
-		$response = API::put(
-			$url,
-			$xml,
-			array("Content-Type: text/xml"),
-			array(
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			)
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$xml->registerXPathNamespace('zxfer', 'http://zotero.org/ns/transfer');
-		$group = $xml->xpath('//atom:entry/atom:content/zxfer:group');
-		$this->assertCount(1, $group);
-		$this->assertEquals($name, $group[0]['name']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"groups?format=versions&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$newVersion = $json->$groupID;
-		$this->assertNotEquals($version, $newVersion);
-		
-		// Check version header on individual group request
-		$response = API::groupGet(
-			$groupID,
-			""
-		);
-		$this->assert200($response);
-		$this->assertEquals($newVersion, $response->getHeader('Last-Modified-Version'));
-		$json = API::getJSONFromResponse($response)['data'];
-		$this->assertEquals($name, $json['name']);
-		$this->assertEquals($description, $json['description']);
-		$this->assertEquals($urlField, $json['url']);
-	}
-	
-	
-	/**
-	 * Changing a group's metadata should change its version
-	 */
-	public function testUpdateMetadataAtom() {
-		$response = API::userGet(
-			self::$config['userID'],
-			"groups?content=json&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		
-		// Get group API URI and version
-		$xml = API::getXMLFromResponse($response);
-		$xml->registerXPathNamespace('atom', 'http://www.w3.org/2005/Atom');
-		$xml->registerXPathNamespace('zapi', 'http://zotero.org/ns/api');
-		$groupID = (string) array_get_first($xml->xpath("//atom:entry/zapi:groupID"));
-		$url = (string) array_get_first($xml->xpath("//atom:entry/atom:link[@rel='self']/@href"));
-		$url = str_replace(self::$config['apiURLPrefix'], '', $url);
-		$version = json_decode(API::parseDataFromAtomEntry($xml)['content'], true)['version'];
-		
-		// Make sure format=versions returns the same version
-		$response = API::userGet(
-			self::$config['userID'],
-			"groups?format=versions&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$this->assertEquals($version, $json->$groupID);
-		
-		// Update group metadata
-		$json = json_decode(array_get_first($xml->xpath("//atom:entry/atom:content")));
-		$xml = new SimpleXMLElement("<group/>");
-		foreach ($json as $key => $val) {
-			switch ($key) {
-			case 'id':
-			case 'members':
-				continue;
-			
-			case 'name':
-				$name = "My Test Group " . uniqid();
-				$xml['name'] = $name;
-				break;
-			
-			case 'description':
-				$description = "This is a test description " . uniqid();
-				$xml->$key = $description;
-				break;
-			
-			case 'url':
-				$urlField = "http://example.com/" . uniqid();
-				$xml->$key = $urlField;
-				break;
-			
-			default:
-				$xml[$key] = $val;
-			}
-		}
-		$xml = trim(preg_replace('/^<\?xml.+\n/', "", $xml->asXML()));
-		
-		$response = API::put(
-			$url,
-			$xml,
-			array("Content-Type: text/xml"),
-			array(
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			)
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$xml->registerXPathNamespace('zxfer', 'http://zotero.org/ns/transfer');
-		$group = $xml->xpath('//atom:entry/atom:content/zxfer:group');
-		$this->assertCount(1, $group);
-		$this->assertEquals($name, $group[0]['name']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"groups?format=versions&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$newVersion = $json->$groupID;
-		$this->assertNotEquals($version, $newVersion);
-		
-		// Check version header on individual group request
-		$response = API::groupGet(
-			$groupID,
-			"?content=json&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$this->assertEquals($newVersion, $response->getHeader('Last-Modified-Version'));
-		$json = json_decode(API::getContentFromResponse($response));
-		$this->assertEquals($name, $json->name);
-		$this->assertEquals($description, $json->description);
-		$this->assertEquals($urlField, $json->url);
-	}
-	
-	
-	public function testUpdateMemberJSON() {
-		$groupID = API::createGroup([
-			'owner' => self::$config['userID'],
-			'type' => 'Private',
-			'libraryReading' => 'all'
-		]);
-		
-		// Get group version
-		$response = API::userGet(
-			self::$config['userID'],
-			"groups?format=versions&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$version = json_decode($response->getBody())->$groupID;
-		
-		$response = API::superPost(
-			"groups/$groupID/users",
-			'<user id="' . self::$config['userID2'] . '" role="member"/>',
-			["Content-Type: text/xml"]
-		);
-		$this->assert200($response);
-		
-		// Group metadata version should have changed
-		$response = API::userGet(
-			self::$config['userID'],
-			"groups?format=versions&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$newVersion = $json->$groupID;
-		$this->assertNotEquals($version, $newVersion);
-		
-		// Check version header on individual group request
-		$response = API::groupGet($groupID, "");
-		$this->assert200($response);
-		$this->assertEquals($newVersion, $response->getHeader('Last-Modified-Version'));
-		
-		API::deleteGroup($groupID);
-	}
-}
-?>
diff --git a/tests/remote/tests/API/3/ItemTest.php b/tests/remote/tests/API/3/ItemTest.php
deleted file mode 100644
index f86377ab..00000000
--- a/tests/remote/tests/API/3/ItemTest.php
+++ /dev/null
@@ -1,2539 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class ItemTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-	}
-	
-	
-	public function testNewEmptyBookItem() {
-		$json = API::createItem("book", false, $this, 'jsonData');
-		$this->assertEquals("book", (string) $json['itemType']);
-		$this->assertTrue("" === $json['title']);
-		return $json;
-	}
-	
-	
-	public function testNewEmptyBookItemMultiple() {
-		$json = API::getItemTemplate("book");
-		
-		$data = array();
-		$json->title = "A";
-		$data[] = $json;
-		$json2 = clone $json;
-		$json2->title = "B";
-		$data[] = $json2;
-		$json3 = clone $json;
-		$json3->title = "C";
-		$json3->numPages = 200;
-		$data[] = $json3;
-		
-		$response = API::postItems($data);
-		$this->assert200($response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(3, $json['successful']);
-		// Deprecated
-		$this->assertCount(3, $json['success']);
-		
-		// Check data in write response
-		for ($i = 0; $i < 3; $i++) {
-			$this->assertEquals($json['successful'][$i]['key'], $json['successful'][$i]['data']['key']);
-			$this->assertEquals($libraryVersion, $json['successful'][$i]['version']);
-			$this->assertEquals($libraryVersion, $json['successful'][$i]['data']['version']);
-			$this->assertEquals($data[$i]->title, $json['successful'][$i]['data']['title']);
-		}
-		//$this->assertArrayNotHasKey('numPages', $json['successful'][0]['data']);
-		//$this->assertArrayNotHasKey('numPages', $json['successful'][1]['data']);
-		$this->assertEquals($data[2]->numPages, $json['successful'][2]['data']['numPages']);
-		
-		// Check in separate request, to be safe
-		$json = API::getItem($json['success'], $this, 'json');
-		$itemJSON = array_shift($json);
-		$this->assertEquals("A", $itemJSON['data']['title']);
-		$itemJSON = array_shift($json);
-		$this->assertEquals("B", $itemJSON['data']['title']);
-		$itemJSON = array_shift($json);
-		$this->assertEquals("C", $itemJSON['data']['title']);
-		$this->assertEquals(200, $itemJSON['data']['numPages']);
-	}
-	
-	
-	/**
-	 * @depends testNewEmptyBookItem
-	 */
-	public function testEditBookItem($json) {
-		$key = $json['key'];
-		$version = $json['version'];
-		
-		$newTitle = "New Title";
-		$numPages = 100;
-		$creatorType = "author";
-		$firstName = "Firstname";
-		$lastName = "Lastname";
-		
-		$json['title'] = $newTitle;
-		$json['numPages'] = $numPages;
-		$json['creators'][] = array(
-			'creatorType' => $creatorType,
-			'firstName' => $firstName,
-			'lastName' => $lastName
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key",
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		$json = API::getItem($key, $this, 'json')['data'];
-		
-		$this->assertEquals($newTitle, $json['title']);
-		$this->assertEquals($numPages, $json['numPages']);
-		$this->assertEquals($creatorType, $json['creators'][0]['creatorType']);
-		$this->assertEquals($firstName, $json['creators'][0]['firstName']);
-		$this->assertEquals($lastName, $json['creators'][0]['lastName']);
-	}
-	
-	
-	public function testDate() {
-		$date = 'Sept 18, 2012';
-		$parsedDate = '2012-09-18';
-		
-		$json = API::createItem("book", array(
-			"date" => $date
-		), $this, 'jsonData');
-		$key = $json['key'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($date, $json['data']['date']);
-		
-		// meta.parsedDate (JSON)
-		$this->assertEquals($parsedDate, $json['meta']['parsedDate']);
-		
-		// zapi:parsedDate (Atom)
-		$xml = API::getItem($key, $this, 'atom');
-		$this->assertEquals($parsedDate, array_get_first($xml->xpath('/atom:entry/zapi:parsedDate')));
-	}
-	
-	
-	public function testDateWithoutDay() {
-		$date = 'Sept 2012';
-		$parsedDate = '2012-09';
-		
-		$json = API::createItem("book", array(
-			"date" => $date
-		), $this, 'jsonData');
-		$key = $json['key'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($date, $json['data']['date']);
-		
-		// meta.parsedDate (JSON)
-		$this->assertEquals($parsedDate, $json['meta']['parsedDate']);
-		
-		// zapi:parsedDate (Atom)
-		$xml = API::getItem($key, $this, 'atom');
-		$this->assertEquals($parsedDate, array_get_first($xml->xpath('/atom:entry/zapi:parsedDate')));
-	}
-	
-	
-	public function testDateWithoutMonth() {
-		$date = '2012';
-		$parsedDate = '2012';
-		
-		$json = API::createItem("book", array(
-			"date" => $date
-		), $this, 'jsonData');
-		$key = $json['key'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($date, $json['data']['date']);
-		
-		// meta.parsedDate (JSON)
-		$this->assertEquals($parsedDate, $json['meta']['parsedDate']);
-		
-		// zapi:parsedDate (Atom)
-		$xml = API::getItem($key, $this, 'atom');
-		$this->assertEquals($parsedDate, array_get_first($xml->xpath('/atom:entry/zapi:parsedDate')));
-	}
-	
-	
-	public function testDateUnparseable() {
-		$json = API::createItem("book", array(
-			"date" => 'n.d.'
-		), $this, 'jsonData');
-		$key = $json['key'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals('n.d.', $json['data']['date']);
-		
-		// meta.parsedDate (JSON)
-		$this->assertArrayNotHasKey('parsedDate', $json['meta']);
-		
-		// zapi:parsedDate (Atom)
-		$xml = API::getItem($key, $this, 'atom');
-		$this->assertCount(0, $xml->xpath('/atom:entry/zapi:parsedDate'));
-	}
-	
-	
-	public function testDateAccessed8601() {
-		$date = '2014-02-01T01:23:45Z';
-		$data = API::createItem("book", array(
-			'accessDate' => $date
-		), $this, 'jsonData');
-		$this->assertEquals($date, $data['accessDate']);
-	}
-	
-	
-	public function testDateAccessed8601TZ() {
-		$date = '2014-02-01T01:23:45-0400';
-		$dateUTC = '2014-02-01T05:23:45Z';
-		$data = API::createItem("book", array(
-			'accessDate' => $date
-		), $this, 'jsonData');
-		$this->assertEquals($dateUTC, $data['accessDate']);
-	}
-	
-	
-	public function testDateAccessedSQL() {
-		$date = '2014-02-01 01:23:45';
-		$date8601 = '2014-02-01T01:23:45Z';
-		$data = API::createItem("book", array(
-			'accessDate' => $date
-		), $this, 'jsonData');
-		$this->assertEquals($date8601, $data['accessDate']);
-	}
-	
-	
-	public function testDateAccessedInvalid() {
-		$date = 'February 1, 2014';
-		$response = API::createItem("book", array(
-			'accessDate' => $date
-		), $this, 'response');
-		$this->assert400ForObject($response, "'accessDate' must be in ISO 8601 or UTC 'YYYY-MM-DD[ hh-mm-dd]' format or 'CURRENT_TIMESTAMP' (February 1, 2014)");
-	}
-	
-	
-	public function testDateAddedNewItem8601() {
-		// In case this is ever extended to other objects
-		$objectType = 'item';
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$dateAdded = "2013-03-03T21:33:53Z";
-		
-		switch ($objectType) {
-		case 'item':
-			$itemData = array(
-				"title" => "Test",
-				"dateAdded" => $dateAdded
-			);
-			$data = API::createItem("videoRecording", $itemData, $this, 'jsonData');
-			break;
-		}
-		
-		$this->assertEquals($dateAdded, $data['dateAdded']);
-	}
-	
-	
-	public function testDateAddedNewItem8601TZ() {
-		// In case this is ever extended to other objects
-		$objectType = 'item';
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$dateAdded = "2013-03-03T17:33:53-0400";
-		$dateAddedUTC = "2013-03-03T21:33:53Z";
-		
-		switch ($objectType) {
-		case 'item':
-			$itemData = array(
-				"title" => "Test",
-				"dateAdded" => $dateAdded
-			);
-			$data = API::createItem("videoRecording", $itemData, $this, 'jsonData');
-			break;
-		}
-		
-		$this->assertEquals($dateAddedUTC, $data['dateAdded']);
-	}
-	
-	
-	public function testDateAddedNewItemSQL() {
-		// In case this is ever extended to other objects
-		$objectType = 'item';
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$dateAdded = "2013-03-03 21:33:53";
-		$dateAdded8601 = "2013-03-03T21:33:53Z";
-		
-		switch ($objectType) {
-		case 'item':
-			$itemData = array(
-				"title" => "Test",
-				"dateAdded" => $dateAdded
-			);
-			$data = API::createItem("videoRecording", $itemData, $this, 'jsonData');
-			break;
-		}
-		
-		$this->assertEquals($dateAdded8601, $data['dateAdded']);
-	}
-	
-	
-	public function testDateAddedExistingItem() {
-		// In case this is ever extended to other objects
-		$objectType = 'item';
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'item':
-			$itemData = [
-				"title" => "Test",
-				"dateAdded" => "2017-03-12T02:48:54Z"
-			];
-			$data = API::createItem("videoRecording", $itemData, $this, 'jsonData');
-			break;
-		}
-		
-		$objectKey = $data['key'];
-		$originalDateAdded = $data['dateAdded'];
-		
-		// If date added hasn't changed, allow
-		$data['title'] = "Test 2";
-		$data['dateAdded'] = $originalDateAdded;
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($data)
-		);
-		$this->assert204($response);
-		$data = API::getItem($objectKey, $this, 'json')['data'];
-		
-		// And even if it's a different timezone
-		$date = \DateTime::createFromFormat(\DateTime::ISO8601, $originalDateAdded);
-		$date->setTimezone(new \DateTimeZone('America/New_York'));
-		$newDateAdded = $date->format('c');
-		
-		$data['title'] = "Test 3";
-		$data['dateAdded'] = $newDateAdded;
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($data)
-		);
-		$this->assert204($response);
-		$data = API::getItem($objectKey, $this, 'json')['data'];
-		
-		// But with a changed dateAdded, disallow
-		$newDateAdded = "2017-04-01T00:00:00Z";
-		$data['title'] = "Test 4";
-		$data['dateAdded'] = $newDateAdded;
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($data)
-		);
-		$this->assert400($response, "'dateAdded' cannot be modified for existing $objectTypePlural");
-		
-		// Unless it's exactly one hour off, because there's a DST bug we haven't fixed
-		// https://github.com/zotero/zotero/issues/1201
-		$newDateAdded = "2017-03-12T01:48:54Z";
-		$data['dateAdded'] = $newDateAdded;
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($data)
-		);
-		$this->assert204($response);
-		$data = API::getItem($objectKey, $this, 'json')['data'];
-		// But the value shouldn't have actually changed
-		$this->assertEquals($originalDateAdded, $data['dateAdded']);
-	}
-	
-	
-	public function testDateModified() {
-		// In case this is ever extended to other objects
-		$objectType = 'item';
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'item':
-			$itemData = array(
-				"title" => "Test"
-			);
-			$json = API::createItem("videoRecording", $itemData, $this, 'jsonData');
-			break;
-		}
-		
-		$objectKey = $json['key'];
-		$dateModified1 = $json['dateModified'];
-		
-		// Make sure we're in the next second
-		sleep(1);
-		
-		//
-		// If no explicit dateModified, use current timestamp
-		//
-		$json['title'] = "Test 2";
-		unset($json['dateModified']);
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		switch ($objectType) {
-		case 'item':
-			$json = API::getItem($objectKey, $this, 'json')['data'];
-			break;
-		}
-		
-		$dateModified2 = $json['dateModified'];
-		$this->assertNotEquals($dateModified1, $dateModified2);
-		
-		// Make sure we're in the next second
-		sleep(1);
-		
-		//
-		// If existing dateModified, use current timestamp
-		//
-		$json['title'] = "Test 3";
-		$json['dateModified'] = trim(preg_replace("/[TZ]/", " ", $dateModified2));
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		switch ($objectType) {
-		case 'item':
-			$json = API::getItem($objectKey, $this, 'json')['data'];
-			break;
-		}
-		
-		$dateModified3 = $json['dateModified'];
-		$this->assertNotEquals($dateModified2, $dateModified3);
-		
-		//
-		// If explicit dateModified, use that
-		//
-		$newDateModified = "2013-03-03T21:33:53Z";
-		$json['title'] = "Test 4";
-		$json['dateModified'] = $newDateModified;
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		switch ($objectType) {
-		case 'item':
-			$json = API::getItem($objectKey, $this, 'json')['data'];
-			break;
-		}
-		$dateModified4 = $json['dateModified'];
-		$this->assertEquals($newDateModified, $dateModified4);
-	}
-	
-	
-	// TODO: Make this the default and remove above after clients update code
-	public function testDateModifiedTmpZoteroClientHack() {
-		// In case this is ever extended to other objects
-		$objectType = 'item';
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'item':
-			$itemData = array(
-				"title" => "Test"
-			);
-			$json = API::createItem("videoRecording", $itemData, $this, 'jsonData');
-			break;
-		}
-		
-		$objectKey = $json['key'];
-		$dateModified1 = $json['dateModified'];
-		
-		// Make sure we're in the next second
-		sleep(1);
-		
-		//
-		// If no explicit dateModified, use current timestamp
-		//
-		$json['title'] = "Test 2";
-		unset($json['dateModified']);
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($json),
-			// TODO: Remove
-			[
-				"User-Agent: Firefox"
-			]
-		);
-		$this->assert204($response);
-		
-		switch ($objectType) {
-		case 'item':
-			$json = API::getItem($objectKey, $this, 'json')['data'];
-			break;
-		}
-		
-		$dateModified2 = $json['dateModified'];
-		$this->assertNotEquals($dateModified1, $dateModified2);
-		
-		// Make sure we're in the next second
-		sleep(1);
-		
-		//
-		// If dateModified provided and hasn't changed, use that
-		//
-		$json['title'] = "Test 3";
-		$json['dateModified'] = trim(preg_replace("/[TZ]/", " ", $dateModified2));
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($json),
-			// TODO: Remove
-			[
-				"User-Agent: Firefox"
-			]
-		);
-		$this->assert204($response);
-		
-		switch ($objectType) {
-		case 'item':
-			$json = API::getItem($objectKey, $this, 'json')['data'];
-			break;
-		}
-		
-		$this->assertEquals($dateModified2, $json['dateModified']);
-		
-		//
-		// If dateModified is provided and has changed, use that
-		//
-		$newDateModified = "2013-03-03T21:33:53Z";
-		$json['title'] = "Test 4";
-		$json['dateModified'] = $newDateModified;
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($json),
-			// TODO: Remove
-			[
-				"User-Agent: Firefox"
-			]
-		);
-		$this->assert204($response);
-		
-		switch ($objectType) {
-		case 'item':
-			$json = API::getItem($objectKey, $this, 'json')['data'];
-			break;
-		}
-		$this->assertEquals($newDateModified, $json['dateModified']);
-	}
-	
-	
-	public function testDateModifiedCollectionChange() {
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		$json = API::createItem("book", ["title" => "Test"], $this, 'jsonData');
-		
-		$objectKey = $json['key'];
-		$dateModified1 = $json['dateModified'];
-		
-		$json['collections'] = [$collectionKey];
-		
-		// Make sure we're in the next second
-		sleep(1);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json])
-		);
-		$this->assert200ForObject($response);
-		
-		$json = API::getItem($objectKey, $this, 'json')['data'];
-		$dateModified2 = $json['dateModified'];
-		
-		// Date Modified shouldn't have changed
-		$this->assertEquals($dateModified1, $dateModified2);
-	}
-	
-	
-	public function testChangeItemType() {
-		$json = API::getItemTemplate("book");
-		$json->title = "Foo";
-		$json->numPages = 100;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$key = API::getFirstSuccessKeyFromResponse($response);
-		$json1 = API::getItem($key, $this, 'json')['data'];
-		$version = $json1['version'];
-		
-		$json2 = API::getItemTemplate("bookSection");
-		
-		foreach ($json2 as $field => &$val) {
-			if ($field != "itemType" && isset($json1[$field])) {
-				$val = $json1[$field];
-			}
-		}
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key",
-			json_encode($json2),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		$json = API::getItem($key, $this, 'json')['data'];
-		$this->assertEquals("bookSection", $json['itemType']);
-		$this->assertEquals("Foo", $json['title']);
-		$this->assertArrayNotHasKey("numPages", $json);
-	}
-	
-	
-	//
-	// PATCH (single item)
-	//
-	public function testPatchItem() {
-		$itemData = array(
-			"title" => "Test"
-		);
-		$json = API::createItem("book", $itemData, $this, 'jsonData');
-		$itemKey = $json['key'];
-		$itemVersion = $json['version'];
-		
-		$patch = function ($context, $config, $itemKey, $itemVersion, &$itemData, $newData) {
-			foreach ($newData as $field => $val) {
-				$itemData[$field] = $val;
-			}
-			$response = API::userPatch(
-				$config['userID'],
-				"items/$itemKey?key=" . $config['apiKey'],
-				json_encode($newData),
-				array(
-					"Content-Type: application/json",
-					"If-Unmodified-Since-Version: $itemVersion"
-				)
-			);
-			$context->assert204($response);
-			$json = API::getItem($itemKey, $this, 'json')['data'];
-			
-			foreach ($itemData as $field => $val) {
-				$context->assertEquals($val, $json[$field]);
-			}
-			$headerVersion = $response->getHeader("Last-Modified-Version");
-			$context->assertGreaterThan($itemVersion, $headerVersion);
-			$context->assertEquals($json['version'], $headerVersion);
-			
-			return $headerVersion;
-		};
-		
-		$newData = array(
-			"date" => "2013"
-		);
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-		
-		$newData = array(
-			"title" => ""
-		);
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-		
-		$newData = array(
-			"tags" => array(
-				array(
-					"tag" => "Foo"
-				)
-			)
-		);
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-		
-		$newData = array(
-			"tags" => array()
-		);
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-		
-		$key = API::createCollection('Test', false, $this, 'key');
-		$newData = array(
-			"collections" => array($key)
-		);
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-		
-		$newData = array(
-			"collections" => array()
-		);
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-	}
-	
-	public function testPatchAttachment() {
-		$json = API::createAttachmentItem("imported_file", [], false, $this, 'jsonData');
-		$itemKey = $json['key'];
-		$itemVersion = $json['version'];
-		
-		$filename = "test.pdf";
-		$mtime = 1234567890000;
-		$md5 = "390d914fdac33e307e5b0e1f3dba9da2";
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$itemKey",
-			json_encode([
-				"filename" => $filename,
-				"mtime" => $mtime,
-				"md5" => $md5,
-			]),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $itemVersion"
-			]
-		);
-		$this->assert204($response);
-		$json = API::getItem($itemKey, $this, 'json')['data'];
-		
-		$this->assertEquals($filename, $json['filename']);
-		$this->assertEquals($mtime, $json['mtime']);
-		$this->assertEquals($md5, $json['md5']);
-		$headerVersion = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($itemVersion, $headerVersion);
-		$this->assertEquals($json['version'], $headerVersion);
-	}
-	
-	public function testPatchNote() {
-		$text = "<p>Test</p>";
-		$newText = "<p>Test 2</p>";
-		$json = API::createNoteItem($text, false, $this, 'jsonData');
-		$itemKey = $json['key'];
-		$itemVersion = $json['version'];
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$itemKey",
-			json_encode([
-				"note" => $newText
-			]),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $itemVersion"
-			]
-		);
-		$this->assert204($response);
-		$json = API::getItem($itemKey, $this, 'json')['data'];
-		
-		$this->assertEquals($newText, $json['note']);
-		$headerVersion = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($itemVersion, $headerVersion);
-		$this->assertEquals($json['version'], $headerVersion);
-	}
-	
-	public function testPatchNoteOnBookError() {
-		$json = API::createItem("book", [], $this, 'jsonData');
-		$itemKey = $json['key'];
-		$itemVersion = $json['version'];
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$itemKey",
-			json_encode([
-				"note" => "Test"
-			]),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $itemVersion"
-			]
-		);
-		$this->assert400($response, "'note' property is valid only for note and attachment items");
-	}
-	
-	//
-	// PATCH (multiple items)
-	//
-	public function testPatchItems() {
-		$itemData = [
-			"title" => "Test"
-		];
-		$json = API::createItem("book", $itemData, $this, 'jsonData');
-		$itemKey = $json['key'];
-		$itemVersion = $json['version'];
-		
-		$patch = function ($context, $config, $itemKey, $itemVersion, &$itemData, $newData) {
-			foreach ($newData as $field => $val) {
-				$itemData[$field] = $val;
-			}
-			$newData['key'] = $itemKey;
-			$newData['version'] = $itemVersion;
-			$response = API::userPost(
-				$config['userID'],
-				"items",
-				json_encode([$newData]),
-				[
-					"Content-Type: application/json"
-				]
-			);
-			$context->assert200ForObject($response);
-			$json = API::getItem($itemKey, $this, 'json')['data'];
-			
-			foreach ($itemData as $field => $val) {
-				$context->assertEquals($val, $json[$field]);
-			}
-			$headerVersion = $response->getHeader("Last-Modified-Version");
-			$context->assertGreaterThan($itemVersion, $headerVersion);
-			$context->assertEquals($json['version'], $headerVersion);
-			
-			return $headerVersion;
-		};
-		
-		$newData = [
-			"date" => "2013"
-		];
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-		
-		$newData = [
-			"title" => ""
-		];
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-		
-		$newData = [
-			"tags" => [
-				[
-					"tag" => "Foo"
-				]
-			]
-		];
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-		
-		$newData = [
-			"tags" => []
-		];
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-		
-		$key = API::createCollection('Test', false, $this, 'key');
-		$newData = [
-			"collections" => [$key]
-		];
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-		
-		$newData = [
-			"collections" => []
-		];
-		$itemVersion = $patch($this, self::$config, $itemKey, $itemVersion, $itemData, $newData);
-	}
-	
-	public function testNewComputerProgramItem() {
-		$data = API::createItem("computerProgram", false, $this, 'jsonData');
-		$key = $data['key'];
-		$this->assertEquals("computerProgram", $data['itemType']);
-		
-		$version = "1.0";
-		$data['versionNumber'] = $version;
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key",
-			json_encode($data),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert204($response);
-		$json = API::getItem($key, $this, 'json');
-		$this->assertEquals($version, $json['data']['versionNumber']);
-	}
-	
-	
-	public function testNewInvalidBookItem() {
-		$json = API::getItemTemplate("book");
-		
-		// Missing item type
-		$json2 = clone $json;
-		unset($json2->itemType);
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json2]),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'itemType' property not provided");
-		
-		// contentType on non-attachment
-		$json2 = clone $json;
-		$json2->contentType = "text/html";
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json2]),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'contentType' is valid only for attachment items");
-		
-		// more tests
-	}
-	
-	
-	public function testEditTopLevelNote() {
-		$noteText = "<p>Test</p>";
-		
-		$json = API::createNoteItem($noteText, null, $this, 'jsonData');
-		$noteText = "<p>Test Test</p>";
-		$json['note'] = $noteText;
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$json['key']}",
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$json['key']}"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response)['data'];
-		$this->assertEquals($noteText, $json['note']);
-	}
-	
-	
-	public function testEditChildNote() {
-		$noteText = "<p>Test</p>";
-		$key = API::createItem("book", [ "title" => "Test" ], $this, 'key');
-		$json = API::createNoteItem($noteText, $key, $this, 'jsonData');
-		$noteText = "<p>Test Test</p>";
-		$json['note'] = $noteText;
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$json['key']}",
-			json_encode($json)
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$json['key']}"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response)['data'];
-		$this->assertEquals($noteText, $json['note']);
-	}
-	
-	
-	public function testConvertChildNoteToParentViaPatch() {
-		$key = API::createItem("book", [ "title" => "Test" ], $this, 'key');
-		$json = API::createNoteItem("", $key, $this, 'jsonData');
-		$json['parentItem'] = false;
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/{$json['key']}",
-			json_encode($json)
-		);
-		$this->assert204($response);
-		$json = API::getItem($json['key'], $this, 'json')['data'];
-		$this->assertArrayNotHasKey('parentItem', $json);
-	}
-	
-	
-	public function test_should_convert_child_note_to_top_level_and_add_to_collection_via_PATCH() {
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		$parentItemKey = API::createItem("book", false, $this, 'key');
-		$noteJSON = API::createNoteItem("", $parentItemKey, $this, 'jsonData');
-		$noteJSON['parentItem'] = false;
-		$noteJSON['collections'] = [$collectionKey];
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/{$noteJSON['key']}",
-			json_encode($noteJSON)
-		);
-		$this->assert204($response);
-		$json = API::getItem($noteJSON['key'], $this, 'json')['data'];
-		$this->assertArrayNotHasKey('parentItem', $json);
-		$this->assertCount(1, $json['collections']);
-		$this->assertEquals($collectionKey, $json['collections'][0]);
-	}
-	
-	
-	public function test_should_convert_child_note_to_top_level_and_add_to_collection_via_PUT() {
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		$parentItemKey = API::createItem("book", false, $this, 'key');
-		$noteJSON = API::createNoteItem("", $parentItemKey, $this, 'jsonData');
-		unset($noteJSON['parentItem']);
-		$noteJSON['collections'] = [$collectionKey];
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$noteJSON['key']}",
-			json_encode($noteJSON)
-		);
-		$this->assert204($response);
-		$json = API::getItem($noteJSON['key'], $this, 'json')['data'];
-		$this->assertArrayNotHasKey('parentItem', $json);
-		$this->assertCount(1, $json['collections']);
-		$this->assertEquals($collectionKey, $json['collections'][0]);
-	}
-	
-	
-	public function testEditTitleWithCollectionInMultipleMode() {
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		
-		$json = API::createItem("book", [
-			"title" => "A",
-			"collections" => [
-				$collectionKey
-			]
-		], $this, 'jsonData');
-		
-		$version = $json['version'];
-		$json['title'] = "B";
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json])
-		);
-		$this->assert200ForObject($response);
-		
-		$json = API::getItem($json['key'], $this, 'json')['data'];
-		$this->assertEquals("B", $json['title']);
-		$this->assertGreaterThan($version, $json['version']);
-	}
-	
-	
-	public function testEditTitleWithTagInMultipleMode() {
-		$tag1 = [
-			"tag" => "foo",
-			"type" => 1
-		];
-		$tag2 = [
-			"tag" => "bar"
-		];
-		
-		$json = API::createItem("book", [
-			"title" => "A",
-			"tags" => [$tag1]
-		], $this, 'jsonData');
-		
-		$this->assertCount(1, $json['tags']);
-		$this->assertEquals($tag1, $json['tags'][0]);
-		
-		$version = $json['version'];
-		$json['title'] = "B";
-		$json['tags'][] = $tag2;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json])
-		);
-		$this->assert200ForObject($response);
-		
-		$json = API::getItem($json['key'], $this, 'json')['data'];
-		$this->assertEquals("B", $json['title']);
-		$this->assertGreaterThan($version, $json['version']);
-		$this->assertCount(2, $json['tags']);
-		$this->assertContains($tag1, $json['tags']);
-		$this->assertContains($tag2, $json['tags']);
-	}
-	
-	
-	public function testNewEmptyAttachmentFields() {
-		$key = API::createItem("book", false, $this, 'key');
-		$json = API::createAttachmentItem("imported_url", [], $key, $this, 'jsonData');
-		$this->assertNull($json['md5']);
-		$this->assertNull($json['mtime']);
-	}
-	
-	
-	public function testNewTopLevelImportedFileAttachment() {
-		$response = API::get("items/new?itemType=attachment&linkMode=imported_file");
-		$json = json_decode($response->getBody());
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-	}
-	
-	
-	public function testNewItemTemplateAttachmentFields() {
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$json = json_decode($response->getBody());
-		$this->assertSame('', $json->url);
-		$this->assertObjectNotHasAttribute('filename', $json);
-		$this->assertObjectNotHasAttribute('path', $json);
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_file");
-		$json = json_decode($response->getBody());
-		$this->assertSame('', $json->path);
-		$this->assertObjectNotHasAttribute('filename', $json);
-		$this->assertObjectNotHasAttribute('url', $json);
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=imported_url");
-		$json = json_decode($response->getBody());
-		$this->assertSame('', $json->filename);
-		$this->assertSame('', $json->url);
-		$this->assertObjectNotHasAttribute('path', $json);
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=imported_file");
-		$json = json_decode($response->getBody());
-		$this->assertSame('', $json->filename);
-		$this->assertObjectNotHasAttribute('path', $json);
-		$this->assertObjectNotHasAttribute('url', $json);
-	}
-	
-	
-	/*
-	Disabled -- see note at Zotero_Item::checkTopLevelAttachment()
-	
-	public function testNewInvalidTopLevelAttachment() {
-		$linkModes = array("linked_url", "imported_url");
-		foreach ($linkModes as $linkMode) {
-			$response = API::get("items/new?itemType=attachment&linkMode=$linkMode");
-			$json = json_decode($response->getBody());
-			
-			$response = API::userPost(
-				self::$config['userID'],
-				"items",
-				json_encode([$json]),
-				array("Content-Type: application/json")
-			);
-			$this->assert400ForObject($response, "Only file attachments and PDFs can be top-level items");
-		}
-	}
-	*/
-	
-	
-	/**
-	 * It should be possible to edit an existing PDF attachment without sending 'contentType'
-	 * (which would cause a new attachment to be rejected)
-	 */
-	/*
-	Disabled -- see note at Zotero_Item::checkTopLevelAttachment()
-	
-	public function testPatchTopLevelAttachment() {
-		$json = API::createAttachmentItem("imported_url", [
-			'title' => 'A',
-			'contentType' => 'application/pdf',
-			'filename' => 'test.pdf'
-		], false, $this, 'jsonData');
-		
-		// With 'attachment' and 'linkMode'
-		$json = [
-			'itemType' => 'attachment',
-			'linkMode' => 'imported_url',
-			'key' => $json['key'],
-			'version' => $json['version'],
-			'title' => 'B'
-		];
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			["Content-Type: application/json"]
-		);
-		$this->assert200ForObject($response);
-		$json = API::getItem($json['key'], $this, 'json')['data'];
-		$this->assertEquals("B", $json['title']);
-		
-		// Without 'linkMode'
-		$json = [
-			'itemType' => 'attachment',
-			'key' => $json['key'],
-			'version' => $json['version'],
-			'title' => 'C'
-		];
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			["Content-Type: application/json"]
-		);
-		$this->assert200ForObject($response);
-		$json = API::getItem($json['key'], $this, 'json')['data'];
-		$this->assertEquals("C", $json['title']);
-		
-		// Without 'itemType' or 'linkMode'
-		$json = [
-			'key' => $json['key'],
-			'version' => $json['version'],
-			'title' => 'D'
-		];
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			["Content-Type: application/json"]
-		);
-		$this->assert200ForObject($response);
-		$json = API::getItem($json['key'], $this, 'json')['data'];
-		$this->assertEquals("D", $json['title']);
-	}*/
-	
-	
-	public function testNewEmptyLinkAttachmentItemWithItemKey() {
-		$key = API::createItem("book", false, $this, 'key');
-		API::createAttachmentItem("linked_url", [], $key, $this, 'json');
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$json = json_decode($response->getBody(), true);
-		$json['parentItem'] = $key;
-		require_once '../../model/Utilities.inc.php';
-		require_once '../../model/ID.inc.php';
-		$json['key'] = \Zotero_ID::getKey();
-		$json['version'] = 0;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert200ForObject($response);
-	}
-	
-	
-	public function testEditEmptyLinkAttachmentItem() {
-		$key = API::createItem("book", false, $this, 'key');
-		$json = API::createAttachmentItem("linked_url", [], $key, $this, 'jsonData');
-		
-		$key = $json['key'];
-		$version = $json['version'];
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key",
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		$json = API::getItem($key, $this, 'json')['data'];
-		// Item shouldn't change
-		$this->assertEquals($version, $json['version']);
-		
-		return $json;
-	}
-	
-	
-	public function testEditEmptyImportedURLAttachmentItem() {
-		$key = API::createItem("book", false, $this, 'key');
-		$json = API::createAttachmentItem("imported_url", [], $key, $this, 'jsonData');
-		
-		$key = $json['key'];
-		$version = $json['version'];
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key",
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		$json = API::getItem($key, $this, 'json')['data'];
-		// Item shouldn't change
-		$this->assertEquals($version, $json['version']);
-		
-		return $json;
-	}
-	
-	
-	/**
-	 * @depends testEditEmptyLinkAttachmentItem
-	 */
-	public function testEditLinkAttachmentItem($json) {
-		$key = $json['key'];
-		$version = $json['version'];
-		
-		$contentType = "text/xml";
-		$charset = "utf-8";
-		
-		$json['contentType'] = $contentType;
-		$json['charset'] = $charset;
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key",
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		$json = API::getItem($key, $this, 'json')['data'];
-		$this->assertEquals($contentType, $json['contentType']);
-		$this->assertEquals($charset, $json['charset']);
-	}
-	
-	/**
-	 * @group attachments
-	 * @group classic-sync
-	 */
-	public function testCreateLinkedFileAttachment() {
-		$key = API::createItem("book", false, $this, 'key');
-		$path = 'attachments:tést.txt';
-		$json = API::createAttachmentItem(
-			"linked_file", [
-				'path' => $path
-			], $key, $this, 'jsonData'
-		);
-		$this->assertEquals('linked_file', $json['linkMode']);
-		// Linked file should have path
-		$this->assertEquals($path, $json['path']);
-		// And shouldn't have other attachment properties
-		$this->assertArrayNotHasKey('filename', $json);
-		$this->assertArrayNotHasKey('md5', $json);
-		$this->assertArrayNotHasKey('mtime', $json);
-		
-		// Until classic sync is removed, paths should be stored as Mozilla-style relative descriptors,
-		// at which point they should be batch converted
-		require_once 'include/sync.inc.php';
-		require_once '../../include/Unicode.inc.php';
-		require_once '../../model/Attachments.inc.php';
-		$sessionID = \Sync::login();
-		$xml = \Sync::updated($sessionID, time() - 10);
-		$path2 = (string) array_get_first($xml->xpath('//items/item[@key="' . $json['key'] . '"]/path'));
-		$this->assertEquals(
-			$path,
-			"attachments:" . \Zotero_Attachments::decodeRelativeDescriptorString(substr($path2, 12))
-		);
-	}
-	
-	/**
-	 * @group attachments
-	 * @group classic-sync
-	 */
-	public function testLinkedFileAttachmentPathViaSync() {
-		require_once 'include/sync.inc.php';
-		require_once '../../include/Unicode.inc.php';
-		require_once '../../model/Attachments.inc.php';
-		require_once '../../model/ID.inc.php';
-		
-		$sessionID = \Sync::login();
-		$xml = \Sync::updated($sessionID, time());
-		
-		$updateKey = (string) $xml['updateKey'];
-		$itemKey = \Zotero_ID::getKey();
-		$filename = "tést.pdf";
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" '
-			. 'key="' . $itemKey . '" '
-			. 'itemType="attachment" '
-			. 'dateAdded="2016-03-07 04:53:20" '
-			. 'dateModified="2016-03-07 04:54:09" '
-			. 'mimeType="application/pdf" '
-			. 'linkMode="2">'
-			// See note in testCreateLinkedFileAttachment
-			. '<path>attachments:' . \Zotero_Attachments::encodeRelativeDescriptorString($filename) . '</path>'
-			. '</item></items></data>';
-		$response = \Sync::upload($sessionID, $updateKey, $data);
-		\Sync::waitForUpload($sessionID, $response, $this);
-		\Sync::logout($sessionID);
-		
-		$json = API::getItem($itemKey, $this, 'json');
-		$this->assertEquals('linked_file', $json['data']['linkMode']);
-		// Linked file should have path
-		$this->assertEquals("attachments:" . $filename, $json['data']['path']);
-	}
-	
-	/**
-	 * @group attachments
-	 * @group classic-sync
-	 */
-	public function testStoredFileAttachmentPathViaSync() {
-		require_once 'include/sync.inc.php';
-		require_once '../../include/Unicode.inc.php';
-		require_once '../../model/Attachments.inc.php';
-		require_once '../../model/ID.inc.php';
-		
-		$sessionID = \Sync::login();
-		$xml = \Sync::updated($sessionID, time());
-		
-		$updateKey = (string) $xml['updateKey'];
-		$itemKey = \Zotero_ID::getKey();
-		$filename = "tést.pdf";
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" '
-			. 'key="' . $itemKey . '" '
-			. 'itemType="attachment" '
-			. 'dateAdded="2016-03-07 04:53:20" '
-			. 'dateModified="2016-03-07 04:54:09" '
-			. 'mimeType="application/pdf" '
-			. 'linkMode="0">'
-			// See note in testCreateLinkedFileAttachment
-			. '<path>storage:' . \Zotero_Attachments::encodeRelativeDescriptorString($filename) . '</path>'
-			. '</item></items></data>';
-		$response = \Sync::upload($sessionID, $updateKey, $data);
-		\Sync::waitForUpload($sessionID, $response, $this);
-		\Sync::logout($sessionID);
-		
-		$json = API::getItem($itemKey, $this, 'json');
-		$this->assertEquals('imported_file', $json['data']['linkMode']);
-		// Linked file should have path
-		$this->assertEquals($filename, $json['data']['filename']);
-	}
-	
-	/**
-	 * Date Modified should be updated when a field is changed if not included in upload
-	 */
-	public function testDateModifiedChangeOnEdit() {
-		$json = API::createAttachmentItem("linked_file", [], false, $this, 'jsonData');
-		$modified = $json['dateModified'];
-		unset($json['dateModified']);
-		$json['note'] = "Test";
-		
-		sleep(1);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$json['key']}",
-			json_encode($json),
-			array("If-Unmodified-Since-Version: " . $json['version'])
-		);
-		$this->assert204($response);
-		
-		$json = API::getItem($json['key'], $this, 'json')['data'];
-		$this->assertNotEquals($modified, $json['dateModified']);
-	}
-	
-	/**
-	 * Date Modified shouldn't be changed if 1) dateModified is provided or 2) certain fields are changed
-	 */
-	public function testDateModifiedNoChange() {
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		
-		$json = API::createItem('book', false, $this, 'jsonData');
-		$modified = $json['dateModified'];
-		
-		for ($i = 1; $i <= 5; $i++) {
-			sleep(1);
-			
-			switch ($i) {
-			case 1:
-				$json['title'] = 'A';
-				break;
-			
-			case 2:
-				// For all subsequent tests, unset field, which would normally cause it to be updated
-				unset($json['dateModified']);
-				
-				$json['collections'] = [$collectionKey];
-				break;
-			
-			case 3:
-				$json['deleted'] = true;
-				break;
-			
-			case 4:
-				$json['deleted'] = false;
-				break;
-			
-			case 5:
-				$json['tags'] = [
-					[
-						'tag' => 'A'
-					]
-				];
-				break;
-			}
-			
-			$response = API::userPost(
-				self::$config['userID'],
-				"items",
-				json_encode([$json]),
-				[
-					"If-Unmodified-Since-Version: " . $json['version'],
-					// TODO: Remove
-					[
-						"User-Agent: Firefox"
-					]
-				]
-			);
-			$this->assert200($response);
-			$json = API::getJSONFromResponse($response)['successful'][0]['data'];
-			$this->assertEquals($modified, $json['dateModified'], "Date Modified changed on loop $i");
-		}
-	}
-	
-	public function testEditAttachmentAtomUpdatedTimestamp() {
-		$xml = API::createAttachmentItem("linked_file", [], false, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$atomUpdated = (string) array_get_first($xml->xpath('//atom:entry/atom:updated'));
-		$json = json_decode($data['content'], true);
-		$json['note'] = "Test";
-		
-		sleep(1);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}",
-			json_encode($json),
-			array("If-Unmodified-Since-Version: " . $data['version'])
-		);
-		$this->assert204($response);
-		
-		$xml = API::getItemXML($data['key']);
-		$atomUpdated2 = (string) array_get_first($xml->xpath('//atom:entry/atom:updated'));
-		$this->assertNotEquals($atomUpdated2, $atomUpdated);
-	}
-	
-	
-	public function testEditAttachmentAtomUpdatedTimestampTmpZoteroClientHack() {
-		$xml = API::createAttachmentItem("linked_file", [], false, $this, 'atom');
-		$data = API::parseDataFromAtomEntry($xml);
-		$atomUpdated = (string) array_get_first($xml->xpath('//atom:entry/atom:updated'));
-		$json = json_decode($data['content'], true);
-		unset($json['dateModified']);
-		$json['note'] = "Test";
-		
-		sleep(1);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$data['key']}",
-			json_encode($json),
-			[
-				"If-Unmodified-Since-Version: " . $data['version'],
-				// TODO: Remove
-				[
-					"User-Agent: Firefox"
-				]
-			]
-		);
-		$this->assert204($response);
-		
-		$xml = API::getItemXML($data['key']);
-		$atomUpdated2 = (string) array_get_first($xml->xpath('//atom:entry/atom:updated'));
-		$this->assertNotEquals($atomUpdated2, $atomUpdated);
-	}
-	
-	
-	public function testNewAttachmentItemInvalidLinkMode() {
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$json = json_decode($response->getBody());
-		
-		// Invalid linkMode
-		$json->linkMode = "invalidName";
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'invalidName' is not a valid linkMode");
-		
-		// Missing linkMode
-		unset($json->linkMode);
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'linkMode' property not provided");
-	}
-	
-	
-	/**
-	 * @depends testNewEmptyBookItem
-	 */
-	public function testNewAttachmentItemMD5OnLinkedURL($json) {
-		$parentKey = $json['key'];
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$json = json_decode($response->getBody());
-		$json->parentItem = $parentKey;
-		
-		$json->md5 = "c7487a750a97722ae1878ed46b215ebe";
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'md5' is valid only for imported attachment items");
-	}
-	
-	
-	/**
-	 * @depends testNewEmptyBookItem
-	 */
-	public function testNewAttachmentItemModTimeOnLinkedURL($json) {
-		$parentKey = $json['key'];
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$json = json_decode($response->getBody());
-		$json->parentItem = $parentKey;
-		
-		$json->mtime = "1332807793000";
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "'mtime' is valid only for imported attachment items");
-	}
-	
-	
-	public function testCannotChangeStoragePropertiesInGroupLibraries() {
-		$key = API::groupCreateItem(
-			self::$config['ownedPrivateGroupID'], "book", [], $this, 'key'
-		);
-		$json = API::groupCreateAttachmentItem(
-			self::$config['ownedPrivateGroupID'], "imported_url", [], $key, $this, 'jsonData'
-		);
-		
-		$key = $json['key'];
-		$version = $json['version'];
-		
-		$props = ["md5", "mtime"];
-		foreach ($props as $prop) {
-			$json2 = $json;
-			$json2[$prop] = "new" . ucwords($prop);
-			$response = API::groupPut(
-				self::$config['ownedPrivateGroupID'],
-				"items/$key",
-				json_encode($json2),
-				array(
-					"Content-Type: application/json",
-					"If-Unmodified-Since-Version: $version"
-				)
-			);
-			$this->assert400($response);
-			$this->assertEquals("Cannot change '$prop' directly in group library", $response->getBody());
-		}
-	}
-	
-	
-	public function testMappedCreatorTypes() {
-		$json = [
-			[
-				'itemType' => 'presentation',
-				'title' => 'Test',
-				'creators' => [
-					[
-						"creatorType" => "author",
-						"name" => "Foo"
-					]
-				]
-			],
-			[
-				'itemType' => 'presentation',
-				'title' => 'Test',
-				'creators' => [
-					[
-						"creatorType" => "editor",
-						"name" => "Foo"
-					]
-				]
-			]
-		];
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode($json)
-		);
-		// 'author' gets mapped automatically
-		$this->assert200ForObject($response);
-		// Others don't
-		$this->assert400ForObject($response, false, 1);
-	}
-	
-	
-	public function testLibraryUser() {
-		$json = API::createItem('book', false, $this, 'json');
-		$this->assertEquals('user', $json['library']['type']);
-		$this->assertEquals(self::$config['userID'], $json['library']['id']);
-		$this->assertEquals(self::$config['username'], $json['library']['name']);
-		$this->assertRegExp('%^https?://[^/]+/' . self::$config['username'] . '$%', $json['library']['links']['alternate']['href']);
-		$this->assertEquals('text/html', $json['library']['links']['alternate']['type']);
-	}
-	
-	
-	public function testLibraryGroup() {
-		$json = API::groupCreateItem(self::$config['ownedPrivateGroupID'], 'book', [], $this, 'json');
-		$this->assertEquals('group', $json['library']['type']);
-		$this->assertEquals(self::$config['ownedPrivateGroupID'], $json['library']['id']);
-		$this->assertEquals(self::$config['ownedPrivateGroupName'], $json['library']['name']);
-		$this->assertRegExp('%^https?://[^/]+/groups/[0-9]+$%', $json['library']['links']['alternate']['href']);
-		$this->assertEquals('text/html', $json['library']['links']['alternate']['type']);
-	}
-	
-	
-	public function testNumChildrenJSON() {
-		$json = API::createItem("book", false, $this, 'json');
-		$this->assertEquals(0, $json['meta']['numChildren']);
-		$key = $json['key'];
-		
-		API::createAttachmentItem("linked_url", [], $key, $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals(1, $json['meta']['numChildren']);
-		
-		API::createNoteItem("Test", $key, $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals(2, $json['meta']['numChildren']);
-	}
-	
-	
-	public function testNumChildrenAtom() {
-		$xml = API::createItem("book", false, $this, 'atom');
-		$this->assertEquals(0, (int) array_get_first($xml->xpath('/atom:entry/zapi:numChildren')));
-		$data = API::parseDataFromAtomEntry($xml);
-		$key = $data['key'];
-		
-		API::createAttachmentItem("linked_url", [], $key, $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('/atom:entry/zapi:numChildren')));
-		
-		API::createNoteItem("Test", $key, $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(2, (int) array_get_first($xml->xpath('/atom:entry/zapi:numChildren')));
-	}
-	
-	
-	public function testTop() {
-		API::userClear(self::$config['userID']);
-		
-		$collectionKey = API::createCollection('Test', false, $this, 'key');
-		$emptyCollectionKey = API::createCollection('Empty', false, $this, 'key');
-		
-		$parentTitle1 = "Parent Title";
-		$childTitle1 = "This is a Test Title";
-		$parentTitle2 = "Another Parent Title";
-		$parentTitle3 = "Yet Another Parent Title";
-		$noteText = "This is a sample note.";
-		$parentTitleSearch = "title";
-		$childTitleSearch = "test";
-		$dates = ["2013", "January 3, 2010", ""];
-		$orderedDates = [$dates[2], $dates[1], $dates[0]];
-		$itemTypes = ["journalArticle", "newspaperArticle", "book"];
-		
-		$parentKeys = [];
-		$childKeys = [];
-		
-		$parentKeys[] = API::createItem($itemTypes[0], [
-			'title' => $parentTitle1,
-			'date' => $dates[0],
-			'collections' => [
-				$collectionKey
-			]
-		], $this, 'key');
-		$childKeys[] = API::createAttachmentItem("linked_url", [
-			'title' => $childTitle1
-		], $parentKeys[0], $this, 'key');
-		
-		$parentKeys[] = API::createItem($itemTypes[1], [
-			'title' => $parentTitle2,
-			'date' => $dates[1]
-		], $this, 'key');
-		$childKeys[] = API::createNoteItem($noteText, $parentKeys[1], $this, 'key');
-		
-		// Create item with deleted child that matches child title search
-		$parentKeys[] = API::createItem($itemTypes[2], [
-			'title' => $parentTitle3
-		], $this, 'key');
-		API::createAttachmentItem("linked_url", [
-			'title' => $childTitle1,
-			'deleted' => true
-		], $parentKeys[sizeOf($parentKeys) - 1], $this, 'key');
-		
-		// Add deleted item with non-deleted child
-		$deletedKey = API::createItem("book", [
-			'title' => "This is a deleted item",
-			'deleted' => true
-		], $this, 'key');
-		API::createNoteItem("This is a child note of a deleted item.", $deletedKey, $this, 'key');
-		
-		// /top, JSON
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$json = API::getJSONFromResponse($response);
-		$done = [];
-		foreach ($json as $item) {
-			$this->assertContains($item['key'], $parentKeys);
-			$this->assertNotContains($item['key'], $done);
-			$done[] = $item['key'];
-		}
-		
-		// /top, Atom
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?content=json"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		foreach ($parentKeys as $parentKey) {
-			$this->assertContains($parentKey, $xpath);
-		}
-		
-		// /top, JSON, in collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(1, $json);
-		$this->assertEquals($parentKeys[0], $json[0]['key']);
-		
-		// /top, Atom, in collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?content=json"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(1, $xpath);
-		$this->assertContains($parentKeys[0], $xpath);
-		
-		// /top, JSON, in empty collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$emptyCollectionKey/items/top"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-		$this->assertTotalResults(0, $response);
-		
-		// /top, keys
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?format=keys"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(sizeOf($parentKeys), $keys);
-		foreach ($parentKeys as $parentKey) {
-			$this->assertContains($parentKey, $keys);
-		}
-		
-		// /top, keys, in collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?format=keys"
-		);
-		$this->assert200($response);
-		$this->assertEquals($parentKeys[0], trim($response->getBody()));
-		
-		// /top with itemKey for parent, JSON
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?itemKey=" . $parentKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($parentKeys[0], $json[0]['key']);
-		
-		// /top with itemKey for parent, Atom
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?content=json&itemKey=" . $parentKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertEquals($parentKeys[0], (string) array_shift($xpath));
-		
-		// /top with itemKey for parent, JSON, in collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?itemKey=" . $parentKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($parentKeys[0], $json[0]['key']);
-		
-		// /top with itemKey for parent, Atom, in collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?content=json&itemKey=" . $parentKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertEquals($parentKeys[0], (string) array_shift($xpath));
-		
-		// /top with itemKey for parent, keys
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?format=keys&itemKey=" . $parentKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertEquals($parentKeys[0], trim($response->getBody()));
-		
-		// /top with itemKey for parent, keys, in collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?format=keys&itemKey=" . $parentKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertEquals($parentKeys[0], trim($response->getBody()));
-		
-		// /top with itemKey for child, JSON
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?itemKey=" . $childKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($parentKeys[0], $json[0]['key']);
-		
-		// /top with itemKey for child, Atom
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?content=json&itemKey=" . $childKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertEquals($parentKeys[0], (string) array_shift($xpath));
-		
-		// /top with itemKey for child, keys
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?format=keys&itemKey=" . $childKeys[0]
-		);
-		$this->assert200($response);
-		$this->assertEquals($parentKeys[0], trim($response->getBody()));
-		
-		// /top, Atom, with q for all items
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?q=$parentTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$json = API::getJSONFromResponse($response);
-		$done = [];
-		foreach ($json as $item) {
-			$this->assertContains($item['key'], $parentKeys);
-			$this->assertNotContains($item['key'], $done);
-			$done[] = $item['key'];
-		}
-		
-		// /top, Atom, with q for all items
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?content=json&q=$parentTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		foreach ($parentKeys as $parentKey) {
-			$this->assertContains($parentKey, $xpath);
-		}
-		
-		// /top, JSON, in collection, with q for all items
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?q=$parentTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertContains($parentKeys[0], $json[0]['key']);
-		
-		// /top, Atom, in collection, with q for all items
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?content=json&q=$parentTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(1, $xpath);
-		$this->assertContains($parentKeys[0], $xpath);
-		
-		// /top, JSON, with q for child item
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?q=$childTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertContains($parentKeys[0], $json[0]['key']);
-		
-		// /top, Atom, with q for child item
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?content=json&q=$childTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(1, $xpath);
-		$this->assertContains($parentKeys[0], $xpath);
-		
-		// /top, JSON, in collection, with q for child item
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?q=$childTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-		// Not currently possible
-		/*$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(1, $xpath);
-		$this->assertContains($parentKeys[0], $xpath);*/
-		
-		// /top, Atom, in collection, with q for child item
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?content=json&q=$childTitleSearch"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-		// Not currently possible
-		/*$this->assertNumResults(1, $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertCount(1, $xpath);
-		$this->assertContains($parentKeys[0], $xpath);*/
-		
-		// /top, JSON, with q for all items, ordered by title
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?q=$parentTitleSearch"
-				. "&order=title"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$json = API::getJSONFromResponse($response);
-		$returnedTitles = [];
-		foreach ($json as $item) {
-			$returnedTitles[] = $item['data']['title'];
-		}
-		$orderedTitles = [$parentTitle1, $parentTitle2, $parentTitle3];
-		sort($orderedTitles);
-		$this->assertEquals($orderedTitles, $returnedTitles);
-		
-		// /top, Atom, with q for all items, ordered by title
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?content=json&q=$parentTitleSearch"
-				. "&order=title"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/atom:title');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		$orderedTitles = [$parentTitle1, $parentTitle2, $parentTitle3];
-		sort($orderedTitles);
-		$orderedResults = array_map(function ($val) {
-			return (string) $val;
-		}, $xpath);
-		$this->assertEquals($orderedTitles, $orderedResults);
-		
-		// /top, Atom, with q for all items, ordered by date asc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?q=$parentTitleSearch"
-				. "&order=date&sort=asc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$json = API::getJSONFromResponse($response);
-		$orderedResults = array_map(function ($val) {
-			return $val['data']['date'];
-		}, $json);
-		$this->assertEquals($orderedDates, $orderedResults);
-		
-		// /top, Atom, with q for all items, ordered by date asc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?content=json&q=$parentTitleSearch"
-				. "&order=date&sort=asc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/atom:content');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		$orderedResults = array_map(function ($val) {
-			return json_decode($val)->date;
-		}, $xpath);
-		$this->assertEquals($orderedDates, $orderedResults);
-		
-		// /top, JSON, with q for all items, ordered by date desc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?q=$parentTitleSearch"
-				. "&order=date&sort=desc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$json = API::getJSONFromResponse($response);
-		$orderedDatesReverse = array_reverse($orderedDates);
-		$orderedResults = array_map(function ($val) {
-			return $val['data']['date'];
-		}, $json);
-		$this->assertEquals($orderedDatesReverse, $orderedResults);
-		
-		// /top, Atom, with q for all items, ordered by date desc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?content=json&q=$parentTitleSearch"
-				. "&order=date&sort=desc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/atom:content');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		$orderedDatesReverse = array_reverse($orderedDates);
-		$orderedResults = array_map(function ($val) {
-			return json_decode($val)->date;
-		}, $xpath);
-		$this->assertEquals($orderedDatesReverse, $orderedResults);
-		
-		// /top, Atom, with q for all items, ordered by item type asc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?q=$parentTitleSearch"
-				. "&order=itemType"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$json = API::getJSONFromResponse($response);
-		$orderedItemTypes = $itemTypes;
-		sort($orderedItemTypes);
-		$orderedResults = array_map(function ($val) {
-			return $val['data']['itemType'];
-		}, $json);
-		$this->assertEquals($orderedItemTypes, $orderedResults);
-		
-		// /top, Atom, with q for all items, ordered by item type asc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?content=json&q=$parentTitleSearch"
-				. "&order=itemType"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:itemType');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		$orderedItemTypes = $itemTypes;
-		sort($orderedItemTypes);
-		$orderedResults = array_map(function ($val) {
-			return (string) $val;
-		}, $xpath);
-		$this->assertEquals($orderedItemTypes, $orderedResults);
-		
-		// /top, Atom, with q for all items, ordered by item type desc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?q=$parentTitleSearch"
-				. "&order=itemType&sort=desc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$json = API::getJSONFromResponse($response);
-		$orderedItemTypes = $itemTypes;
-		rsort($orderedItemTypes);
-		$orderedResults = array_map(function ($val) {
-			return $val['data']['itemType'];
-		}, $json);
-		$this->assertEquals($orderedItemTypes, $orderedResults);
-		
-		// /top, Atom, with q for all items, ordered by item type desc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?content=json&q=$parentTitleSearch"
-				. "&order=itemType&sort=desc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($parentKeys), $response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:itemType');
-		$this->assertCount(sizeOf($parentKeys), $xpath);
-		$orderedItemTypes = $itemTypes;
-		rsort($orderedItemTypes);
-		$orderedResults = array_map(function ($val) {
-			return (string) $val;
-		}, $xpath);
-		$this->assertEquals($orderedItemTypes, $orderedResults);
-	}
-	
-	
-	public function testIncludeTrashed() {
-		API::userClear(self::$config['userID']);
-		
-		$key1 = API::createItem("book", false, $this, 'key');
-		$key2 = API::createItem("book", [
-			"deleted" => 1
-		], $this, 'key');
-		$key3 = API::createNoteItem("", $key1, $this, 'key');
-		
-		// All three items should show up with includeTrashed=1
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?includeTrashed=1"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(3, $json);
-		$keys = [$json[0]['key'], $json[1]['key'], $json[2]['key']];
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-		$this->assertContains($key3, $keys);
-		
-		// ?itemKey should show the deleted item
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?itemKey=$key2,$key3&includeTrashed=1"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json);
-		$keys = [$json[0]['key'], $json[1]['key']];
-		$this->assertContains($key2, $keys);
-		$this->assertContains($key3, $keys);
-		
-		// /top should show the deleted item
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?includeTrashed=1"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json);
-		$keys = [$json[0]['key'], $json[1]['key']];
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-	}
-	
-	
-	public function testTrash() {
-		API::userClear(self::$config['userID']);
-		
-		$key1 = API::createItem("book", false, $this, 'key');
-		$key2 = API::createItem("book", [
-			"deleted" => 1
-		], $this, 'key');
-		
-		// Item should show up in trash
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/trash"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(1, $json);
-		$this->assertEquals($key2, $json[0]['key']);
-		
-		// And not show up in main items
-		$response = API::userGet(
-			self::$config['userID'],
-			"items"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(1, $json);
-		$this->assertEquals($key1, $json[0]['key']);
-		
-		// Including with ?itemKey
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?itemKey=" . $key2
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(0, $json);
-	}
-	
-	
-	public function testParentItem() {
-		$json = API::createItem("book", false, $this, 'jsonData');
-		$parentKey = $json['key'];
-		$parentVersion = $json['version'];
-		
-		$json = API::createAttachmentItem("linked_file", [], $parentKey, $this, 'jsonData');
-		$childKey = $json['key'];
-		$childVersion = $json['version'];
-		
-		$this->assertArrayHasKey('parentItem', $json);
-		$this->assertEquals($parentKey, $json['parentItem']);
-		
-		// Remove the parent, making the child a standalone attachment
-		unset($json['parentItem']);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$childKey",
-			json_encode($json),
-			array("If-Unmodified-Since-Version: " . $childVersion)
-		);
-		$this->assert204($response);
-		
-		$json = API::getItem($childKey, $this, 'json')['data'];
-		$this->assertArrayNotHasKey('parentItem', $json);
-	}
-	
-	
-	public function testParentItemPatch() {
-		$json = API::createItem("book", false, $this, 'jsonData');
-		$parentKey = $json['key'];
-		$parentVersion = $json['version'];
-		
-		$json = API::createAttachmentItem("linked_file", [], $parentKey, $this, 'jsonData');
-		$childKey = $json['key'];
-		$childVersion = $json['version'];
-		
-		$this->assertArrayHasKey('parentItem', $json);
-		$this->assertEquals($parentKey, $json['parentItem']);
-		
-		$json = array(
-			'title' => 'Test'
-		);
-		
-		// With PATCH, parent shouldn't be removed even though unspecified
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$childKey",
-			json_encode($json),
-			array("If-Unmodified-Since-Version: " . $childVersion)
-		);
-		$this->assert204($response);
-		
-		$json = API::getItem($childKey, $this, 'json')['data'];
-		$this->assertArrayHasKey('parentItem', $json);
-		$childVersion = $json['version'];
-		
-		// But it should be removed with parentItem: false
-		$json = [
-			'parentItem' => false
-		];
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/$childKey",
-			json_encode($json),
-			["If-Unmodified-Since-Version: " . $childVersion]
-		);
-		$this->assert204($response);
-		$json = API::getItem($childKey, $this, 'json')['data'];
-		$this->assertArrayNotHasKey('parentItem', $json);
-	}
-	
-	
-	public function test_should_return_409_on_missing_parent() {
-		$missingParentKey = "BDARG2AV";
-		$json = API::createNoteItem("<p>test</p>", $missingParentKey, $this);
-		$this->assert409ForObject($json, "Parent item $missingParentKey not found");
-		$this->assertEquals($missingParentKey, $json['failed'][0]['data']['parentItem']);
-	}
-	
-	
-	public function test_should_return_409_on_missing_parent_if_parent_failed() {
-		// Collection
-		$collectionKey = API::createCollection("A", null, $this, 'key');
-		
-		$version = API::getLibraryVersion();
-		$parentKey = "BDARG2AV";
-		$tag = \Zotero_Utilities::randomString(300);
-		
-		// Parent item
-		$item1JSON = API::getItemTemplate("book");
-		$item1JSON->key = $parentKey;
-		$item1JSON->creators = [
-			[
-                "firstName" => "A.",
-                "lastName" => "Nespola",
-                "creatorType" => "author"
-            ]
-		];
-		$item1JSON->tags = [
-			[
-				"tag" => "A"
-			],
-			[
-				"tag" => $tag
-			]
-		];
-		$item1JSON->collections = [$collectionKey];
-		// Child note
-		$item2JSON = API::getItemTemplate("note");
-		$item2JSON->parentItem = $parentKey;
-		// Child attachment with note
-		// TODO: Use template function
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$item3JSON = json_decode($response->getBody());
-		$item3JSON->parentItem = $parentKey;
-		$item3JSON->note = "Test";
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$item1JSON, $item2JSON, $item3JSON]),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assert413ForObject($json, null, 0);
-		$this->assert409ForObject($json, "Parent item $parentKey not found", 1);
-		$this->assertEquals($parentKey, $json['failed'][1]['data']['parentItem']);
-		$this->assert409ForObject($json, "Parent item $parentKey not found", 2);
-		$this->assertEquals($parentKey, $json['failed'][2]['data']['parentItem']);
-	}
-	
-	
-	public function test_should_return_409_on_missing_collection() {
-		$missingCollectionKey = "BDARG2AV";
-		$json = API::createItem("book", [ 'collections' => [$missingCollectionKey] ], $this);
-		$this->assert409ForObject($json, "Collection $missingCollectionKey not found");
-		$this->assertEquals($missingCollectionKey, $json['failed'][0]['data']['collection']);
-	}
-	
-	
-	public function test_should_allow_emoji_in_title() {
-		$title = "🐶"; // 4-byte character
-		
-		$key = API::createItem("book", array("title" => $title), $this, 'key');
-		
-		// Test entry (JSON)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key"
-		);
-		$this->assertContains("\"title\": \"$title\"", $response->getBody());
-		
-		// Test feed (JSON)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items"
-		);
-		$this->assertContains("\"title\": \"$title\"", $response->getBody());
-		
-		// Test entry (Atom)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?content=json"
-		);
-		$this->assertContains("\"title\": \"$title\"", $response->getBody());
-		
-		// Test feed (Atom)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?content=json"
-		);
-		$this->assertContains("\"title\": \"$title\"", $response->getBody());
-	}
-}
diff --git a/tests/remote/tests/API/3/KeysTest.php b/tests/remote/tests/API/3/KeysTest.php
deleted file mode 100644
index 41e9717d..00000000
--- a/tests/remote/tests/API/3/KeysTest.php
+++ /dev/null
@@ -1,303 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2014 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class KeysTest extends APITests {
-	public function testGetKeys() {
-		// No anonymous access
-		API::useAPIKey("");
-		$response = API::userGet(
-			self::$config['userID'],
-			'keys'
-		);
-		$this->assert403($response);
-		
-		// No access with user's API key
-		API::useAPIKey(self::$config['apiKey']);
-		$response = API::userGet(
-			self::$config['userID'],
-			'keys'
-		);
-		$this->assert403($response);
-		
-		// Root access
-		$response = API::userGet(
-			self::$config['userID'],
-			'keys',
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertTrue(is_array($json));
-		$this->assertTrue(sizeOf($json) > 0);
-		$this->assertArrayHasKey('dateAdded', $json[0]);
-		$this->assertArrayHasKey('lastUsed', $json[0]);
-		$this->assertArrayHasKey('recentIPs', $json[0]);
-	}
-	
-	
-	public function testGetKeyInfoCurrent() {
-		API::useAPIKey("");
-		$response = API::get(
-			'keys/current',
-			[
-				"Zotero-API-Key" => self::$config['apiKey']
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals(self::$config['apiKey'], $json['key']);
-		$this->assertEquals(self::$config['userID'], $json['userID']);
-		$this->arrayHasKey("user", $json['access']);
-		$this->arrayHasKey("groups", $json['access']);
-		$this->assertTrue($json['access']['user']['library']);
-		$this->assertTrue($json['access']['user']['files']);
-		$this->assertTrue($json['access']['user']['notes']);
-		$this->assertTrue($json['access']['user']['write']);
-		$this->assertTrue($json['access']['groups']['all']['library']);
-		$this->assertTrue($json['access']['groups']['all']['write']);
-		$this->assertArrayNotHasKey('name', $json);
-		$this->assertArrayNotHasKey('dateAdded', $json);
-		$this->assertArrayNotHasKey('lastUsed', $json);
-		$this->assertArrayNotHasKey('recentIPs', $json);
-	}
-	
-	
-	public function testGetKeyInfoCurrentWithoutHeader() {
-		API::useAPIKey("");
-		$response = API::get('keys/current');
-		$this->assert403($response);
-	}
-	
-	
-	public function testGetKeyInfoByPath() {
-		API::useAPIKey("");
-		$response = API::get('keys/' . self::$config['apiKey']);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals(self::$config['apiKey'], $json['key']);
-		$this->assertEquals(self::$config['userID'], $json['userID']);
-		$this->arrayHasKey("user", $json['access']);
-		$this->arrayHasKey("groups", $json['access']);
-		$this->assertTrue($json['access']['user']['library']);
-		$this->assertTrue($json['access']['user']['files']);
-		$this->assertTrue($json['access']['user']['notes']);
-		$this->assertTrue($json['access']['user']['write']);
-		$this->assertTrue($json['access']['groups']['all']['library']);
-		$this->assertTrue($json['access']['groups']['all']['write']);
-		$this->assertArrayNotHasKey('name', $json);
-		$this->assertArrayNotHasKey('dateAdded', $json);
-		$this->assertArrayNotHasKey('lastUsed', $json);
-		$this->assertArrayNotHasKey('recentIPs', $json);
-	}
-	
-	
-	// Deprecated
-	public function testGetKeyInfoWithUser() {
-		API::useAPIKey("");
-		$response = API::userGet(
-			self::$config['userID'],
-			'keys/' . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals(self::$config['apiKey'], $json['key']);
-		$this->assertEquals(self::$config['userID'], $json['userID']);
-		$this->arrayHasKey("user", $json['access']);
-		$this->arrayHasKey("groups", $json['access']);
-		$this->assertTrue($json['access']['user']['library']);
-		$this->assertTrue($json['access']['user']['files']);
-		$this->assertTrue($json['access']['user']['notes']);
-		$this->assertTrue($json['access']['user']['write']);
-		$this->assertTrue($json['access']['groups']['all']['library']);
-		$this->assertTrue($json['access']['groups']['all']['write']);
-	}
-	
-	
-	public function testKeyCreateAndDelete() {
-		API::useAPIKey("");
-		
-		$name = "Test " . uniqid();
-		
-		// Can't create anonymously
-		$response = API::userPost(
-			self::$config['userID'],
-			'keys',
-			json_encode([
-				'name' => $name,
-				'access' => [
-					'user' => [
-						'library' => true
-					]
-				]
-			])
-		);
-		$this->assert403($response);
-		
-		// Create as root
-		$response = API::userPost(
-			self::$config['userID'],
-			'keys',
-			json_encode([
-				'name' => $name,
-				'access' => [
-					'user' => [
-						'library' => true
-					]
-				]
-			]),
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		$this->assert201($response);
-		$json = API::getJSONFromResponse($response);
-		$key = $json['key'];
-		$this->assertEquals($json['name'], $name);
-		$this->assertEquals(['user' => ['library' => true, 'files' => true]], $json['access']);
-		
-		// Delete anonymously (with embedded key)
-		$response = API::userDelete(
-			self::$config['userID'],
-			"keys/current",
-			[
-				"Zotero-API-Key" => $key
-			]
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"keys/current",
-			[
-				"Zotero-API-Key" => $key
-			]
-		);
-		$this->assert403($response);
-	}
-	
-	
-	// Private API
-	public function testKeyCreateAndModifyWithCredentials() {
-		API::useAPIKey("");
-		
-		$name = "Test " . uniqid();
-		
-		// Can't create on /users/:userID/keys with credentials
-		$response = API::userPost(
-			self::$config['userID'],
-			'keys',
-			json_encode([
-				'username' => self::$config['username'],
-				'password' => self::$config['password'],
-				'name' => $name,
-				'access' => [
-					'user' => [
-						'library' => true
-					]
-				]
-			])
-		);
-		$this->assert403($response);
-		
-		// Create with credentials
-		$response = API::post(
-			'keys',
-			json_encode([
-				'username' => self::$config['username'],
-				'password' => self::$config['password'],
-				'name' => $name,
-				'access' => [
-					'user' => [
-						'library' => true
-					]
-				]
-			]),
-			[],
-			[]
-		);
-		$this->assert201($response);
-		$json = API::getJSONFromResponse($response);
-		$key = $json['key'];
-		$this->assertEquals($json['userID'], self::$config['userID']);
-		$this->assertEquals($json['name'], $name);
-		$this->assertEquals(['user' => ['library' => true, 'files' => true]], $json['access']);
-		
-		$name = "Test " . uniqid();
-		
-		// Can't modify on /users/:userID/keys/:key with credentials
-		$response = API::userPut(
-			self::$config['userID'],
-			"keys/$key",
-			json_encode([
-				'username' => self::$config['username'],
-				'password' => self::$config['password'],
-				'name' => $name,
-				'access' => [
-					'user' => [
-						'library' => true
-					]
-				]
-			])
-		);
-		$this->assert403($response);
-		
-		// Modify with credentials
-		$response = API::put(
-			"keys/$key",
-			json_encode([
-				'username' => self::$config['username'],
-				'password' => self::$config['password'],
-				'name' => $name,
-				'access' => [
-					'user' => [
-						'library' => true
-					]
-				]
-			])
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$key = $json['key'];
-		$this->assertEquals($json['name'], $name);
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"keys/$key"
-		);
-		$this->assert204($response);
-	}
-}
diff --git a/tests/remote/tests/API/3/MappingsTest.php b/tests/remote/tests/API/3/MappingsTest.php
deleted file mode 100644
index 52462d00..00000000
--- a/tests/remote/tests/API/3/MappingsTest.php
+++ /dev/null
@@ -1,82 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class MappingsTests extends APITests {
-	public function testNewItem() {
-		$response = API::get("items/new?itemType=invalidItemType");
-		$this->assert400($response);
-		
-		$response = API::get("items/new?itemType=book");
-		$this->assert200($response);
-		$this->assertContentType('application/json', $response);
-		$json = json_decode($response->getBody());
-		$this->assertEquals('book', $json->itemType);
-	}
-	
-	
-	public function testNewItemAttachment() {
-		$response = API::get("items/new?itemType=attachment");
-		$this->assert400($response);
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=invalidLinkMode");
-		$this->assert400($response);
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_url");
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$this->assertNotNull($json);
-		$this->assertObjectHasAttribute('url', $json);
-		
-		$response = API::get("items/new?itemType=attachment&linkMode=linked_file");
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$this->assertNotNull($json);
-		$this->assertObjectNotHasAttribute('url', $json);
-	}
-	
-	public function testComputerProgramVersion() {
-		$response = API::get("items/new?itemType=computerProgram");
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$this->assertObjectHasAttribute('versionNumber', $json);
-		$this->assertObjectNotHasAttribute('version', $json);
-		
-		$response = API::get("itemTypeFields?itemType=computerProgram");
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$fields = array_map(function ($val) {
-			return $val->field;
-		}, $json);
-		$this->assertContains('versionNumber', $fields);
-		$this->assertNotContains('version', $fields);
-	}
-}
-?>
\ No newline at end of file
diff --git a/tests/remote/tests/API/3/NoteTest.php b/tests/remote/tests/API/3/NoteTest.php
deleted file mode 100644
index 99476d5b..00000000
--- a/tests/remote/tests/API/3/NoteTest.php
+++ /dev/null
@@ -1,193 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class NoteTests extends APITests {
-	private $content;
-	private $json;
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	
-	public function setUp() {
-		parent::setUp();
-		
-		// Create too-long note content
-		$this->content = str_repeat("1234567890", 25001);
-		
-		// Create JSON template
-		$this->json = API::getItemTemplate("note");
-		$this->json->note = $this->content;
-	}
-	
-	
-	public function test_utf8mb4_note() {
-		$note = "<p>🐻</p>"; // 4-byte character
-		$json = API::getItemTemplate('note');
-		$json->note = $note;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			["Content-Type: application/json"]
-		);
-		
-		$this->assert200ForObject($response);
-		
-		$json = API::getJSONFromResponse($response);
-		$json = $json['successful'][0]['data'];
-		$this->assertSame($note, $json['note']);
-	}
-	
-	
-	public function testNoteTooLong() {
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$this->json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert413ForObject(
-			$response,
-			"Note '1234567890123456789012345678901234567890123456789012345678901234567890123456789...' too long"
-		);
-	}
-	
-	// Blank first two lines
-	public function testNoteTooLongBlankFirstLines() {
-		$this->json->note = " \n \n" . $this->content;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$this->json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert413ForObject(
-			$response,
-			"Note '1234567890123456789012345678901234567890123456789012345678901234567890123456789...' too long"
-		);
-	}
-	
-	
-	public function testNoteTooLongBlankFirstLinesHTML() {
-		$this->json->note = "\n<p> </p>\n<p> </p>\n" . $this->content;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$this->json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert413ForObject(
-			$response,
-			"Note '1234567890123456789012345678901234567890123456789012345678901234567890123...' too long"
-		);
-	}
-	
-	
-	public function testNoteTooLongTitlePlusNewlines() {
-		$this->json->note = "Full Text:\n\n" . $this->content;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$this->json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert413ForObject(
-			$response,
-			"Note 'Full Text: 1234567890123456789012345678901234567890123456789012345678901234567...' too long"
-		);
-	}
-	
-	
-	// All content within HTML tags
-	public function testNoteTooLongWithinHTMLTags() {
-		$this->json->note = " \n<p><!-- " . $this->content . " --></p>";
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$this->json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert413ForObject(
-			$response,
-			"Note '<p><!-- 1234567890123456789012345678901234567890123456789012345678901234...' too long"
-		);
-	}
-	
-	
-	public function testSaveHTML() {
-		$content = '<p>Foo & Bar</p>';
-		$json = API::createNoteItem($content, false, $this, 'json');
-		$this->assertEquals($content, $json['data']['note']);
-	}
-	
-	
-	public function testSaveHTMLAtom() {
-		$content = '<p>Foo & Bar</p>';
-		$xml = API::createNoteItem($content, false, $this, 'atom');
-		$this->assertEquals($content, json_decode($xml->content)->note);
-	}
-	
-	
-	public function testSaveUnchangedSanitizedNote() {
-		$json = API::createNoteItem("<span >Foo</span>", false, $this, 'json');
-		$response = API::postItem($json['data']);
-		$json = API::getJSONFromResponse($response);
-		$this->assertArrayHasKey(0, $json['unchanged']);
-	}
-	
-	
-	public function test_should_allow_zotero_links_in_notes() {
-		$json = API::createNoteItem('<p>Test</p>', false, $this, 'json');
-		
-		$val = '<p><a href="zotero://select/library/items/ABCD2345">Test</a></p>';
-		$json['data']['note'] = $val;
-		
-		$response = API::postItem($json['data']);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($val, $json['successful'][0]['data']['note']);
-	}
-}
-?>
diff --git a/tests/remote/tests/API/3/NotificationsTest.php b/tests/remote/tests/API/3/NotificationsTest.php
deleted file mode 100644
index b3b27c68..00000000
--- a/tests/remote/tests/API/3/NotificationsTest.php
+++ /dev/null
@@ -1,562 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2015 Zotero
-                     https://www.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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-/**
- * @group sns
- */
-class NotificationsTests extends APITests {
-	public function testNewItemNotification() {
-		$response = API::createItem("book", false, $this, 'response');
-		$version = API::getJSONFromResponse($response)['successful'][0]['version'];
-		$this->assertCountNotifications(1, $response);
-		$this->assertHasNotification([
-			'event' => 'topicUpdated',
-			'topic' => '/users/' . self::$config['userID'],
-			'version' => $version
-		], $response);
-	}
-	
-	
-	public function testModifyItemNotification() {
-		$json = API::createItem("book", false, $this, 'jsonData');
-		$json['title'] = 'test';
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$json['key']}",
-			json_encode($json)
-		);
-		$version = $response->getHeader('Last-Modified-Version');
-		$this->assertCountNotifications(1, $response);
-		$this->assertHasNotification([
-			'event' => 'topicUpdated',
-			'topic' => '/users/' . self::$config['userID'],
-			'version' => $version
-		], $response);
-	}
-	
-	
-	public function testDeleteItemNotification() {
-		$json = API::createItem("book", false, $this, 'json');
-		$response = API::userDelete(
-			self::$config['userID'],
-			"items/{$json['key']}",
-			[
-				"If-Unmodified-Since-Version: {$json['version']}"
-			]
-		);
-		$version = $response->getHeader('Last-Modified-Version');
-		$this->assertCountNotifications(1, $response);
-		$this->assertHasNotification([
-			'event' => 'topicUpdated',
-			'topic' => '/users/' . self::$config['userID'],
-			'version' => $version
-		], $response);
-	}
-	
-	
-	public function testKeyCreateNotification() {
-		API::useAPIKey("");
-		
-		$name = "Test " . uniqid();
-		$response = API::superPost(
-			'users/' . self::$config['userID'] . '/keys',
-			json_encode([
-				'name' => $name,
-				'access' => [
-					'user' => [
-						'library' => true
-					]
-				]
-			])
-		);
-		try {
-			// No notification when creating a new key
-			$this->assertCountNotifications(0, $response);
-		}
-		// Clean up
-		finally {
-			$json = API::getJSONFromResponse($response);
-			$key = $json['key'];
-			$response = API::userDelete(
-				self::$config['userID'],
-				"keys/$key"
-			);
-		}
-	}
-	
-	
-	/**
-	 * Grant an API key access to a group
-	 */
-	public function testKeyAddLibraryNotification() {
-		API::useAPIKey("");
-		
-		$name = "Test " . uniqid();
-		$json = [
-			'name' => $name,
-			'access' => [
-				'user' => [
-					'library' => true
-				]
-			]
-		];
-		
-		$response = API::superPost(
-			'users/' . self::$config['userID'] . '/keys?showid=1',
-			json_encode($json)
-		);
-		$this->assert201($response);
-		try {
-			$json = API::getJSONFromResponse($response);
-			$apiKey = $json['key'];
-			$apiKeyID = $json['id'];
-			
-			// Add a group to the key, which should trigger topicAdded
-			$json['access']['groups'][self::$config['ownedPrivateGroupID']] = [
-				'library' => true,
-				'write' => true
-			];
-			$response = API::superPut(
-				"keys/$apiKey",
-				json_encode($json)
-			);
-			$this->assert200($response);
-			
-			$this->assertCountNotifications(1, $response);
-			$this->assertHasNotification([
-				'event' => 'topicAdded',
-				'apiKeyID' => $apiKeyID,
-				'topic' => '/groups/' . self::$config['ownedPrivateGroupID']
-			], $response);
-		}
-		// Clean up
-		finally {
-			$response = API::superDelete("keys/$apiKey");
-		}
-	}
-	
-	
-	/**
-	 * Make a group public, which should trigger topicAdded
-	 */
-	
-	/**
-	 * Show public groups in topic list for single-key requests
-	 */
-	
-	/**
-	 * Revoke access for a group from an API key
-	 */
-	public function testKeyRemoveLibraryNotification() {
-		API::useAPIKey("");
-		
-		$json = $this->createKey(self::$config['userID'], [
-			'user' => [
-				'library' => true
-			],
-			'groups' => [
-				self::$config['ownedPrivateGroupID'] => [
-					'library' => true
-				]
-			]
-		]);
-		$apiKey = $json['key'];
-		$apiKeyID = $json['id'];
-		
-		try {
-			// Remove group from the key, which should trigger topicRemoved
-			unset($json['access']['groups']);
-			$response = API::superPut(
-				"keys/$apiKey",
-				json_encode($json)
-			);
-			$this->assert200($response);
-			
-			$this->assertCountNotifications(1, $response);
-			$this->assertHasNotification([
-				'event' => 'topicRemoved',
-				'apiKeyID' => $apiKeyID,
-				'topic' => '/groups/' . self::$config['ownedPrivateGroupID']
-			], $response);
-		}
-		// Clean up
-		finally {
-			$response = API::superDelete("keys/$apiKey");
-		}
-	}
-	
-	
-	/**
-	 * Grant access to all groups to an API key that has access to a single group
-	 */
-	public function testKeyAddAllGroupsToNoneNotification() {
-		API::useAPIKey("");
-		
-		$json = $this->createKey(self::$config['userID'], [
-			'user' => [
-				'library' => true
-			]
-		]);
-		$apiKey = $json['key'];
-		$apiKeyID = $json['id'];
-		
-		try {
-			// Get list of available groups
-			$response = API::superGet('users/' . self::$config['userID'] . '/groups');
-			$groupIDs = array_map(function ($group) {
-				return $group['id'];
-			}, API::getJSONFromResponse($response));
-			
-			// Add all groups to the key, which should trigger topicAdded for each groups
-			$json['access']['groups'][0] = [
-				'library' => true
-			];
-			$response = API::superPut(
-				"keys/$apiKey",
-				json_encode($json)
-			);
-			$this->assert200($response);
-			
-			$this->assertCountNotifications(sizeOf($groupIDs), $response);
-			foreach ($groupIDs as $groupID) {
-				$this->assertHasNotification([
-					'event' => 'topicAdded',
-					'apiKeyID' => $apiKeyID,
-					'topic' => '/groups/' . $groupID
-				], $response);
-			}
-		}
-		// Clean up
-		finally {
-			$response = API::superDelete("keys/$apiKey");
-		}
-	}
-	
-	
-	/**
-	 * Grant access to all groups to an API key that has access to a single group
-	 */
-	public function testKeyAddAllGroupsToOneNotification() {
-		API::useAPIKey("");
-		
-		$json = $this->createKey(self::$config['userID'], [
-			'user' => [
-				'library' => true
-			],
-			'groups' => [
-				self::$config['ownedPrivateGroupID'] => [
-					'library' => true
-				]
-			]
-		]);
-		$apiKey = $json['key'];
-		$apiKeyID = $json['id'];
-		
-		try {
-			// Get list of available groups
-			$response = API::superGet('users/' . self::$config['userID'] . '/groups');
-			$groupIDs = array_map(function ($group) {
-				return $group['id'];
-			}, API::getJSONFromResponse($response));
-			// Remove group that already had access
-			$groupIDs = array_diff($groupIDs, [self::$config['ownedPrivateGroupID']]);
-			
-			// Add all groups to the key, which should trigger topicAdded for each new group
-			// but not the group that was previously accessible
-			unset($json['access']['groups'][self::$config['ownedPrivateGroupID']]);
-			$json['access']['groups']['all'] = [
-				'library' => true
-			];
-			$response = API::superPut(
-				"keys/$apiKey",
-				json_encode($json)
-			);
-			$this->assert200($response);
-			
-			$this->assertCountNotifications(sizeOf($groupIDs), $response);
-			foreach ($groupIDs as $groupID) {
-				$this->assertHasNotification([
-					'event' => 'topicAdded',
-					'apiKeyID' => $apiKeyID,
-					'topic' => '/groups/' . $groupID
-				], $response);
-			}
-		}
-		// Clean up
-		finally {
-			$response = API::superDelete("keys/$apiKey");
-		}
-	}
-	
-	
-	/**
-	 * Revoke access for a group from an API key that has access to all groups
-	 */
-	public function testKeyRemoveLibraryFromAllGroupsNotification() {
-		API::useAPIKey("");
-		
-		$removedGroup = self::$config['ownedPrivateGroupID'];
-		
-		$json = $this->createKeyWithAllGroupAccess(self::$config['userID']);
-		$apiKey = $json['key'];
-		$apiKeyID = $json['id'];
-		
-		try {
-			// Get list of available groups
-			API::useAPIKey($apiKey);
-			$response = API::userGet(
-				self::$config['userID'],
-				'groups'
-			);
-			$groupIDs = array_map(function ($group) {
-				return $group['id'];
-			}, API::getJSONFromResponse($response));
-			
-			// Remove one group, and replace access array with new set
-			$groupIDs = array_diff($groupIDs, [$removedGroup]);
-			unset($json['access']['groups']['all']);
-			foreach ($groupIDs as $groupID) {
-				$json['access']['groups'][$groupID]['library'] = true;
-			}
-			
-			// Post new JSON, which should trigger topicRemoved for the removed group
-			API::useAPIKey("");
-			$response = API::superPut(
-				"keys/$apiKey",
-				json_encode($json)
-			);
-			$this->assert200($response);
-			
-			$this->assertCountNotifications(1, $response);
-			foreach ($groupIDs as $groupID) {
-				$this->assertHasNotification([
-					'event' => 'topicRemoved',
-					'apiKeyID' => $apiKeyID,
-					'topic' => '/groups/' . $removedGroup
-				], $response);
-			}
-		}
-		// Clean up
-		finally {
-			$response = API::superDelete("keys/$apiKey");
-		}
-	}
-	
-	
-	/**
-	 * Create and delete group owned by user
-	 */
-	public function testAddDeleteOwnedGroupNotification() {
-		API::useAPIKey("");
-		
-		$json = $this->createKeyWithAllGroupAccess(self::$config['userID']);
-		$apiKey = $json['key'];
-		
-		try {
-			$allGroupsKeys = $this->getKeysWithAllGroupAccess(self::$config['userID']);
-			
-			// Create new group owned by user
-			$response = $this->createGroup(self::$config['userID']);
-			$xml = API::getXMLFromResponse($response);
-			$groupID = (int) $xml->xpath("/atom:entry/zapi:groupID")[0];
-			
-			try {
-				$this->assertCountNotifications(sizeOf($allGroupsKeys), $response);
-				foreach ($allGroupsKeys as $key) {
-					$response2 = API::superGet("keys/$key?showid=1");
-					$json2 = API::getJSONFromResponse($response2);
-					$this->assertHasNotification([
-						'event' => 'topicAdded',
-						'apiKeyID' => $json2['id'],
-						'topic' => '/groups/' . $groupID
-					], $response);
-				}
-			}
-			// Delete group
-			finally {
-				$response = API::superDelete("groups/$groupID");
-				$this->assert204($response);
-				$this->assertCountNotifications(1, $response);
-				$this->assertHasNotification([
-					'event' => 'topicDeleted',
-					'topic' => '/groups/' . $groupID
-				], $response);
-			}
-		}
-		// Delete key
-		finally {
-			$response = API::superDelete("keys/$apiKey");
-			try {
-				$this->assert204($response);
-			}
-			catch (Exception $e) {
-				var_dump($e);
-			}
-		}
-	}
-	
-	
-	public function testAddRemoveGroupMemberNotification() {
-		API::useAPIKey("");
-		
-		$json = $this->createKeyWithAllGroupAccess(self::$config['userID']);
-		$apiKey = $json['key'];
-		
-		try {
-			// Get all keys with access to all groups
-			$allGroupsKeys = $this->getKeysWithAllGroupAccess(self::$config['userID']);
-			
-			// Create group owned by another user
-			$response = $this->createGroup(self::$config['userID2']);
-			$xml = API::getXMLFromResponse($response);
-			$groupID = (int) $xml->xpath("/atom:entry/zapi:groupID")[0];
-			
-			try {
-				// Add user to group
-				$response = API::superPost(
-					"groups/$groupID/users",
-					'<user id="' . self::$config['userID']. '" role="member"/>'
-				);
-				$this->assert200($response);
-				$this->assertCountNotifications(sizeOf($allGroupsKeys), $response);
-				foreach ($allGroupsKeys as $key) {
-					$response2 = API::superGet("keys/$key?showid=1");
-					$json2 = API::getJSONFromResponse($response2);
-					$this->assertHasNotification([
-						'event' => 'topicAdded',
-						'apiKeyID' => $json2['id'],
-						'topic' => '/groups/' . $groupID
-					], $response);
-				}
-				
-				// Remove user from group
-				$response = API::superDelete("groups/$groupID/users/" . self::$config['userID']);
-				$this->assert204($response);
-				$this->assertCountNotifications(sizeOf($allGroupsKeys), $response);
-				foreach ($allGroupsKeys as $key) {
-					$response2 = API::superGet("keys/$key?showid=1");
-					$json2 = API::getJSONFromResponse($response2);
-					$this->assertHasNotification([
-						'event' => 'topicRemoved',
-						'apiKeyID' => $json2['id'],
-						'topic' => '/groups/' . $groupID
-					], $response);
-				}
-			}
-			// Delete group
-			finally {
-				$response = API::superDelete("groups/$groupID");
-				$this->assert204($response);
-				$this->assertCountNotifications(1, $response);
-				$this->assertHasNotification([
-					'event' => 'topicDeleted',
-					'topic' => '/groups/' . $groupID
-				], $response);
-			}
-		}
-		// Delete key
-		finally {
-			$response = API::superDelete("keys/$apiKey");
-			try {
-				$this->assert204($response);
-			}
-			catch (Exception $e) {
-				var_dump($e);
-			}
-		}
-	}
-	
-	
-	//
-	// Private functions
-	//
-	private function createKey($userID, $access) {
-		$name = "Test " . uniqid();
-		$json = [
-			'name' => $name,
-			'access' => $access
-		];
-		$response = API::superPost(
-			"users/$userID/keys?showid=1",
-			json_encode($json)
-		);
-		$this->assert201($response);
-		$json = API::getJSONFromResponse($response);
-		return $json;
-	}
-	
-	private function createKeyWithAllGroupAccess($userID) {
-		return $this->createKey($userID, [
-			'user' => [
-				'library' => true
-			],
-			'groups' => [
-				'all' => [
-					'library' => true
-				]
-			]
-		]);
-	}
-	
-	private function createGroup($ownerID) {
-		// Create new group owned by another
-		$xml = new \SimpleXMLElement('<group/>');
-		$xml['owner'] = $ownerID;
-		$xml['name'] = 'Test';
-		$xml['type'] = 'Private';
-		$xml['libraryEditing'] = 'admins';
-		$xml['libraryReading'] = 'members';
-		$xml['fileEditing'] = 'admins';
-		$xml['description'] = 'This is a description';
-		$xml['url'] = '';
-		$xml['hasImage'] = 0;
-		$xml = $xml->asXML();
-		$response = API::superPost(
-			'groups',
-			$xml
-		);
-		$this->assert201($response);
-		return $response;
-	}
-	
-	private function getKeysWithAllGroupAccess($userID) {
-		$response = API::superGet("users/$userID/keys");
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		return array_map(
-			function ($keyObj) {
-				return $keyObj['key'];
-			},
-			array_filter($json, function ($keyObj) {
-				return !empty($keyObj['access']['groups']['all']['library']);
-			})
-		);
-	}
-}
diff --git a/tests/remote/tests/API/3/ObjectTest.php b/tests/remote/tests/API/3/ObjectTest.php
deleted file mode 100644
index 2e94f88f..00000000
--- a/tests/remote/tests/API/3/ObjectTest.php
+++ /dev/null
@@ -1,785 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class ObjectTests extends APITests {
-	public function setUp() {
-		parent::setUp();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public function tearDown() {
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testMultiObjectGet() {
-		$this->_testMultiObjectGet('collection');
-		$this->_testMultiObjectGet('item');
-		$this->_testMultiObjectGet('search');
-	}
-	
-	public function testCreateByPut() {
-		$this->_testCreateByPut('collection');
-		$this->_testCreateByPut('item');
-		$this->_testCreateByPut('search');
-	}
-	
-	private function _testCreateByPut($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$json = API::createUnsavedDataObject($objectType);
-		require_once '../../model/ID.inc.php';
-		$key = \Zotero_ID::getKey();
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$key",
-			json_encode($json),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: 0"
-			]
-		);
-		$this->assert204($response);
-	}
-	
-	
-	public function testSingleObjectDelete() {
-		$this->_testSingleObjectDelete('collection');
-		$this->_testSingleObjectDelete('item');
-		$this->_testSingleObjectDelete('search');
-	}
-	
-	
-	public function testMultiObjectDelete() {
-		$this->_testMultiObjectDelete('collection');
-		$this->_testMultiObjectDelete('item');
-		$this->_testMultiObjectDelete('search');
-	}
-	
-	
-	public function testDeleted() {
-		$self = $this;
-		
-		API::userClear(self::$config['userID']);
-		
-		//
-		// Create objects
-		//
-		$objectKeys = array();
-		$objectKeys['tag'] = array("foo", "bar");
-		
-		$objectKeys['collection'][] = API::createCollection("Name", false, $this, 'key');
-		$objectKeys['collection'][] = API::createCollection("Name", false, $this, 'key');
-		$objectKeys['collection'][] = API::createCollection("Name", false, $this, 'key');
-		$objectKeys['item'][] = API::createItem(
-			"book",
-			array(
-				"title" => "Title",
-				"tags" => array_map(function ($tag) {
-					return array("tag" => $tag);
-				}, $objectKeys['tag'])
-			),
-			$this,
-			'key'
-		);
-		$objectKeys['item'][] = API::createItem("book", array("title" => "Title"), $this, 'key');
-		$objectKeys['item'][] = API::createItem("book", array("title" => "Title"), $this, 'key');
-		$objectKeys['search'][] = API::createSearch("Name", 'default', $this, 'key');
-		$objectKeys['search'][] = API::createSearch("Name", 'default', $this, 'key');
-		$objectKeys['search'][] = API::createSearch("Name", 'default', $this, 'key');
-		
-		// Get library version
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'] . "&format=keys&limit=1"
-		);
-		$libraryVersion1 = $response->getHeader("Last-Modified-Version");
-		
-		// Delete first object
-		$config = self::$config;
-		$func = function ($objectType, $libraryVersion) use ($config, $self, $objectKeys) {
-			$objectTypePlural = API::getPluralObjectType($objectType);
-			$keyProp = $objectType . "Key";
-			$response = API::userDelete(
-				$config['userID'],
-				"$objectTypePlural?key=" . $config['apiKey']
-					. "&$keyProp=" . $objectKeys[$objectType][0],
-				array("If-Unmodified-Since-Version: " . $libraryVersion)
-			);
-			$self->assert204($response);
-			return $response->getHeader("Last-Modified-Version");
-		};
-		$tempLibraryVersion = $func('collection', $libraryVersion1);
-		$tempLibraryVersion = $func('item', $tempLibraryVersion);
-		$tempLibraryVersion = $func('search', $tempLibraryVersion);
-		$libraryVersion2 = $tempLibraryVersion;
-		
-		// Delete second and third objects
-		$func = function ($objectType, $libraryVersion) use ($config, $self, $objectKeys) {
-			$objectTypePlural = API::getPluralObjectType($objectType);
-			$keyProp = $objectType . "Key";
-			$response = API::userDelete(
-				$config['userID'],
-				"$objectTypePlural?key=" . $config['apiKey']
-					. "&$keyProp=" . implode(',', array_slice($objectKeys[$objectType], 1)),
-				array("If-Unmodified-Since-Version: " . $libraryVersion)
-			);
-			$self->assert204($response);
-			return $response->getHeader("Last-Modified-Version");
-		};
-		$tempLibraryVersion = $func('collection', $tempLibraryVersion);
-		$tempLibraryVersion = $func('item', $tempLibraryVersion);
-		$libraryVersion3 = $func('search', $tempLibraryVersion);
-		
-		
-		// Request all deleted objects
-		$response = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey'] . "&since=$libraryVersion1"
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertNotNull($version);
-		$this->assertContentType("application/json", $response);
-		
-		// Make sure 'newer' is equivalent
-		$responseNewer = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey'] . "&newer=$libraryVersion1"
-		);
-		$this->assertEquals($response->getStatus(), $responseNewer->getStatus());
-		$this->assertEquals($response->getBody(), $responseNewer->getBody());
-		$this->assertEquals($response->getHeader('Last-Modified-Version'), $responseNewer->getHeader('Last-Modified-Version'));
-		$this->assertEquals($response->getHeader('Content-Type'), $responseNewer->getHeader('Content-Type'));
-		
-		// Verify keys
-		$func = function ($json, $objectType, $objectKeys) use ($self) {
-			$objectTypePlural = API::getPluralObjectType($objectType);
-			$self->assertArrayHasKey($objectTypePlural, $json);
-			$self->assertCount(sizeOf($objectKeys), $json[$objectTypePlural]);
-			foreach ($objectKeys as $key) {
-				$self->assertContains($key, $json[$objectTypePlural]);
-			}
-		};
-		$func($json, 'collection', $objectKeys['collection']);
-		$func($json, 'item', $objectKeys['item']);
-		$func($json, 'search', $objectKeys['search']);
-		// Tags aren't deleted by removing from items
-		$func($json, 'tag', []);
-		
-		
-		// Request second and third deleted objects
-		$response = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey'] . "&newer=$libraryVersion2"
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertNotNull($version);
-		$this->assertContentType("application/json", $response);
-		
-		// Verify keys
-		$func = function ($json, $objectType, $objectKeys) use ($self) {
-			$objectTypePlural = API::getPluralObjectType($objectType);
-			$self->assertArrayHasKey($objectTypePlural, $json);
-			$self->assertCount(sizeOf($objectKeys), $json[$objectTypePlural]);
-			foreach ($objectKeys as $key) {
-				$self->assertContains($key, $json[$objectTypePlural]);
-			}
-		};
-		$func($json, 'collection', array_slice($objectKeys['collection'], 1));
-		$func($json, 'item', array_slice($objectKeys['item'], 1));
-		$func($json, 'search', array_slice($objectKeys['search'], 1));
-		// Tags aren't deleted by removing from items
-		$func($json, 'tag', []);
-		
-		
-		// Explicit tag deletion
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?key=" . self::$config['apiKey']
-			. "&tag=" . implode('%20||%20', $objectKeys['tag']),
-			array("If-Unmodified-Since-Version: " . $libraryVersion3)
-		);
-		$self->assert204($response);
-		
-		// Verify deleted tags
-		$response = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey'] . "&newer=$libraryVersion3"
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$func($json, 'tag', $objectKeys['tag']);
-	}
-	
-	
-	public function testEmptyVersionsResponse() {
-		$this->_testEmptyVersionsResponse('collection');
-		$this->_testEmptyVersionsResponse('item');
-		$this->_testEmptyVersionsResponse('search');
-	}
-	
-	
-	private function _testEmptyVersionsResponse($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$keyProp = $objectType . "Key";
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?format=versions&$keyProp=NNNNNNNN"
-		);
-		$this->assert200($response);
-		$json = json_decode($response->getBody());
-		$this->assertInternalType('object', $json);
-		$this->assertCount(0, get_object_vars($json));
-	}
-	
-	
-	public function testResponseJSONPost() {
-		$this->_testResponseJSONPost('collection');
-		$this->_testResponseJSONPost('item');
-		$this->_testResponseJSONPost('search');
-	}
-	
-	
-	public function testResponseJSONPut() {
-		$this->_testResponseJSONPut('collection');
-		$this->_testResponseJSONPut('item');
-		$this->_testResponseJSONPut('search');
-	}
-	
-	
-	public function testPartialWriteFailure() {
-		$this->_testPartialWriteFailure('collection');
-		$this->_testPartialWriteFailure('item');
-		$this->_testPartialWriteFailure('search');
-	}
-	
-	
-	public function testPartialWriteFailureWithUnchanged() {
-		$this->_testPartialWriteFailureWithUnchanged('collection');
-		$this->_testPartialWriteFailureWithUnchanged('item');
-		$this->_testPartialWriteFailureWithUnchanged('search');
-	}
-	
-	
-	public function testMultiObjectWriteInvalidObject() {
-		$this->_testMultiObjectWriteInvalidObject('collection');
-		$this->_testMultiObjectWriteInvalidObject('item');
-		$this->_testMultiObjectWriteInvalidObject('search');
-	}
-	
-	
-	private function _testMultiObjectGet($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$keyProp = $objectType . "Key";
-		
-		$keys = [];
-		switch ($objectType) {
-		case 'collection':
-			$keys[] = API::createCollection("Name", false, $this, 'key');
-			$keys[] = API::createCollection("Name", false, $this, 'key');
-			API::createCollection("Name", false, $this, 'key');
-			break;
-		
-		case 'item':
-			$keys[] = API::createItem("book", array("title" => "Title"), $this, 'key');
-			$keys[] = API::createItem("book", array("title" => "Title"), $this, 'key');
-			API::createItem("book", array("title" => "Title"), $this, 'key');
-			break;
-		
-		case 'search':
-			$keys[] = API::createSearch("Name", 'default', $this, 'key');
-			$keys[] = API::createSearch("Name", 'default', $this, 'key');
-			API::createSearch("Name", 'default', $this, 'key');
-			break;
-		}
-		
-		// HEAD request should include Total-Results
-		$response = API::userHead(
-			self::$config['userID'],
-			"$objectTypePlural?$keyProp=" . implode(',', $keys)
-		);
-		$this->assert200($response);
-		$this->assertTotalResults(sizeOf($keys), $response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?$keyProp=" . implode(',', $keys)
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($keys), $response);
-		
-		// Trailing comma in itemKey parameter
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?$keyProp=" . implode(',', $keys) . ","
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($keys), $response);
-	}
-	
-	
-	private function _testSingleObjectDelete($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'collection':
-			$json = API::createCollection("Name", false, $this, 'json');
-			break;
-		
-		case 'item':
-			$json = API::createItem("book", array("title" => "Title"), $this, 'json');
-			break;
-		
-		case 'search':
-			$json = API::createSearch("Name", 'default', $this, 'json');
-			break;
-		}
-		
-		$objectKey = $json['key'];
-		$objectVersion = $json['version'];
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			array(
-				"If-Unmodified-Since-Version: " . $objectVersion
-			)
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey"
-		);
-		$this->assert404($response);
-	}
-	
-	
-	private function _testMultiObjectDelete($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$keyProp = $objectType . "Key";
-		
-		$deleteKeys = array();
-		$keepKeys = array();
-		switch ($objectType) {
-		case 'collection':
-			$deleteKeys[] = API::createCollection("Name", false, $this, 'key');
-			$deleteKeys[] = API::createCollection("Name", false, $this, 'key');
-			$keepKeys[] = API::createCollection("Name", false, $this, 'key');
-			break;
-		
-		case 'item':
-			$deleteKeys[] = API::createItem("book", array("title" => "Title"), $this, 'key');
-			$deleteKeys[] = API::createItem("book", array("title" => "Title"), $this, 'key');
-			$keepKeys[] = API::createItem("book", array("title" => "Title"), $this, 'key');
-			break;
-		
-		case 'search':
-			$deleteKeys[] = API::createSearch("Name", 'default', $this, 'key');
-			$deleteKeys[] = API::createSearch("Name", 'default', $this, 'key');
-			$keepKeys[] = API::createSearch("Name", 'default', $this, 'key');
-			break;
-		}
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($deleteKeys) + sizeOf($keepKeys), $response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural?$keyProp=" . implode(',', $deleteKeys),
-			array(
-				"If-Unmodified-Since-Version: " . $libraryVersion
-			)
-		);
-		$this->assert204($response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($keepKeys), $response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?$keyProp=" . implode(',', $keepKeys)
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($keepKeys), $response);
-		
-		// Add trailing comma to itemKey param, to test key parsing
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural?$keyProp=" . implode(',', $keepKeys) . ",",
-			array(
-				"If-Unmodified-Since-Version: " . $libraryVersion
-			)
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-	}
-	
-	
-	private function _testResponseJSONPost($objectType) {
-		API::userClear(self::$config['userID']);
-		
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'collection':
-			$json1 = ["name" => "Test 1"];
-			$json2 = ["name" => "Test 2"];
-			break;
-		
-		case 'item':
-			$json1 = API::getItemTemplate('book');
-			$json2 = clone $json1;
-			$json1->title = "Test 1";
-			$json2->title = "Test 2";
-			break;
-		
-		case 'search':
-			$conditions = array(
-				array(
-					'condition' => 'title',
-					'operator' => 'contains',
-					'value' => 'value'
-				)
-			);
-			$json1 = ["name" => "Test 1", "conditions" => $conditions];
-			$json2 = ["name" => "Test 2", "conditions" => $conditions];
-			break;
-		}
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			$objectTypePlural,
-			json_encode([$json1, $json2]),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assert200ForObject($response, false, 0);
-		$this->assert200ForObject($response, false, 1);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			$objectTypePlural
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		switch ($objectType) {
-		case 'item':
-			$json[0]['data']['title'] = $json[0]['data']['title'] == "Test 1" ? "Test A" : "Test B";
-			$json[1]['data']['title'] = $json[1]['data']['title'] == "Test 2" ? "Test B" : "Test A";
-			break;
-		
-		case 'collection':
-		case 'search':
-			$json[0]['data']['name'] = $json[0]['data']['name'] == "Test 1" ? "Test A" : "Test B";
-			$json[1]['data']['name'] = $json[1]['data']['name'] == "Test 2" ? "Test B" : "Test A";
-			break;
-		}
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			$objectTypePlural,
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assert200ForObject($response, false, 0);
-		$this->assert200ForObject($response, false, 1);
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			$objectTypePlural
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		switch ($objectTypePlural) {
-		case 'item':
-			$this->assertEquals("Test A", $json[0]['data']['title']);
-			$this->assertEquals("Test B", $json[1]['data']['title']);
-			break;
-		
-		case 'collection':
-		case 'search':
-			$this->assertEquals("Test A", $json[0]['data']['name']);
-			$this->assertEquals("Test B", $json[1]['data']['name']);
-			break;
-		}
-	}
-	
-	
-	private function _testResponseJSONPut($objectType) {
-		API::userClear(self::$config['userID']);
-		
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'collection':
-			$json1 = ["name" => "Test 1"];
-			break;
-		
-		case 'item':
-			$json1 = API::getItemTemplate('book');
-			$json1->title = "Test 1";
-			break;
-		
-		case 'search':
-			$conditions = array(
-				array(
-					'condition' => 'title',
-					'operator' => 'contains',
-					'value' => 'value'
-				)
-			);
-			$json1 = ["name" => "Test 1", "conditions" => $conditions];
-			break;
-		}
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			$objectTypePlural,
-			json_encode([$json1]),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assert200ForObject($response, false, 0);
-		$objectKey = $json['successful'][0]['key'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		switch ($objectType) {
-		case 'item':
-			$json['data']['title'] = "Test 2";
-			break;
-		
-		case 'collection':
-		case 'search':
-			$json['data']['name'] = "Test 2";
-			break;
-		}
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		switch ($objectTypePlural) {
-		case 'item':
-			$this->assertEquals("Test 2", $json['data']['title']);
-			break;
-		
-		case 'collection':
-		case 'search':
-			$this->assertEquals("Test 2", $json['data']['name']);
-			break;
-		}
-	}
-	
-	
-	private function _testPartialWriteFailure($objectType) {
-		API::userClear(self::$config['userID']);
-		
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'collection':
-			$json1 = array("name" => "Test");
-			$json2 = array("name" => str_repeat("1234567890", 6554));
-			$json3 = array("name" => "Test");
-			break;
-		
-		case 'item':
-			$json1 = API::getItemTemplate('book');
-			$json2 = clone $json1;
-			$json3 = clone $json1;
-			$json2->title = str_repeat("1234567890", 6554);
-			break;
-		
-		case 'search':
-			$conditions = array(
-				array(
-					'condition' => 'title',
-					'operator' => 'contains',
-					'value' => 'value'
-				)
-			);
-			$json1 = array("name" => "Test", "conditions" => $conditions);
-			$json2 = array("name" => str_repeat("1234567890", 6554), "conditions" => $conditions);
-			$json3 = array("name" => "Test", "conditions" => $conditions);
-			break;
-		}
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json1, $json2, $json3]),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assert200ForObject($response, false, 0);
-		$this->assert413ForObject($response, false, 1);
-		$this->assert200ForObject($response, false, 2);
-		$successKeys = API::getSuccessfulKeysFromResponse($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?format=keys&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		foreach ($successKeys as $key) {
-			$this->assertContains($key, $keys);
-		}
-	}
-	
-	
-	private function _testPartialWriteFailureWithUnchanged($objectType) {
-		API::userClear(self::$config['userID']);
-		
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'collection':
-			$json1 = API::createCollection("Test", false, $this, 'jsonData');
-			$json2 = array("name" => str_repeat("1234567890", 6554));
-			$json3 = array("name" => "Test");
-			break;
-		
-		case 'item':
-			$json1 = API::createItem("book", array("title" => "Title"), $this, 'jsonData');
-			$json2 = API::getItemTemplate('book');
-			$json3 = clone $json2;
-			$json2->title = str_repeat("1234567890", 6554);
-			break;
-		
-		case 'search':
-			$conditions = array(
-				array(
-					'condition' => 'title',
-					'operator' => 'contains',
-					'value' => 'value'
-				)
-			);
-			$json1 = API::createSearch("Name", $conditions, $this, 'jsonData');
-			$json2 = array("name" => str_repeat("1234567890", 6554), "conditions" => $conditions);
-			$json3 = array("name" => "Test", "conditions" => $conditions);
-			break;
-		}
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json1, $json2, $json3]),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertUnchangedForObject($response, 0);
-		$this->assert413ForObject($response, false, 1);
-		$this->assert200ForObject($response, false, 2);
-		$successKeys = API::getSuccessfulKeysFromResponse($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?format=keys&key=" . self::$config['apiKey']
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		foreach ($successKeys as $key) {
-			$this->assertContains($key, $keys);
-		}
-	}
-	
-	
-	private function _testMultiObjectWriteInvalidObject($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode(["foo" => "bar"]),
-			array("Content-Type: application/json")
-		);
-		$this->assert400($response, "Uploaded data must be a JSON array");
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([[], ""]),
-			array("Content-Type: application/json")
-		);
-		$this->assert400ForObject($response, "Invalid value for index 0 in uploaded data; expected JSON $objectType object");
-		$this->assert400ForObject($response, "Invalid value for index 1 in uploaded data; expected JSON $objectType object", 1);
-	}
-}
diff --git a/tests/remote/tests/API/3/ParamsTest.php b/tests/remote/tests/API/3/ParamsTest.php
deleted file mode 100644
index ed6870b4..00000000
--- a/tests/remote/tests/API/3/ParamsTest.php
+++ /dev/null
@@ -1,583 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class ParamsTests extends APITests {
-	private static $collectionKeys = array();
-	private static $itemKeys = array();
-	private static $searchKeys = array();
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function setUp() {
-		parent::setUp();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testFormatKeys() {
-		//
-		// Collections
-		//
-		for ($i=0; $i<5; $i++) {
-			self::$collectionKeys[] = API::createCollection("Test", false, null, 'key');
-		}
-		
-		//
-		// Items
-		//
-		for ($i=0; $i<5; $i++) {
-			self::$itemKeys[] = API::createItem("book", false, null, 'key');
-		}
-		self::$itemKeys[] = API::createAttachmentItem("imported_file", [], false, null, 'key');
-		
-		//
-		// Searches
-		//
-		for ($i=0; $i<5; $i++) {
-			self::$searchKeys[] = API::createSearch("Test", 'default', null, 'key');
-		}
-		
-		$this->_testFormatKeys('collection');
-		$this->_testFormatKeys('item');
-		$this->_testFormatKeys('search');
-		
-		$this->_testFormatKeysSorted('collection');
-		$this->_testFormatKeysSorted('item');
-		$this->_testFormatKeysSorted('search');
-	}
-	
-	
-	private function _testFormatKeys($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$keysVar = $objectType . "Keys";
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?format=keys"
-		);
-		$this->assert200($response);
-		
-		$keys = explode("\n", trim($response->getBody()));
-		sort($keys);
-		$this->assertEmpty(
-			array_merge(
-				array_diff(self::$$keysVar, $keys), array_diff($keys, self::$$keysVar)
-			)
-		);
-	}
-	
-	
-	private function _testFormatKeysSorted($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$keysVar = $objectType . "Keys";
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?format=keys&order=title"
-		);
-		$this->assert200($response);
-		
-		$keys = explode("\n", trim($response->getBody()));
-		sort($keys);
-		$this->assertEmpty(
-			array_merge(
-				array_diff(self::$$keysVar, $keys), array_diff($keys, self::$$keysVar)
-			)
-		);
-	}
-	
-	
-	public function testObjectKeyParameter() {
-		$this->_testObjectKeyParameter('collection');
-		$this->_testObjectKeyParameter('item');
-		$this->_testObjectKeyParameter('search');
-	}
-	
-	
-	private function _testObjectKeyParameter($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$jsonArray = array();
-		
-		switch ($objectType) {
-		case 'collection':
-			$jsonArray[] = API::createCollection("Name", false, $this, 'jsonData');
-			$jsonArray[] = API::createCollection("Name", false, $this, 'jsonData');
-			break;
-		
-		case 'item':
-			$jsonArray[] = API::createItem("book", false, $this, 'jsonData');
-			$jsonArray[] = API::createItem("book", false, $this, 'jsonData');
-			break;
-		
-		case 'search':
-			$jsonArray[] = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this,
-				'jsonData'
-			);
-			$jsonArray[] = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this,
-				'jsonData'
-			);
-			break;
-		}
-		
-		$keys = [];
-		foreach ($jsonArray as $json) {
-			$keys[] = $json['key'];
-		}
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?{$objectType}Key={$keys[0]}"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$this->assertTotalResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($keys[0], $json[0]['key']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?{$objectType}Key={$keys[0]},{$keys[1]}&order={$objectType}KeyList"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(2, $response);
-		$this->assertTotalResults(2, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($keys[0], $json[0]['key']);
-		$this->assertEquals($keys[1], $json[1]['key']);
-	}
-	
-	
-	public function testPagination() {
-		self::_testPagination('collection');
-		self::_testPagination('group');
-		self::_testPagination('item');
-		self::_testPagination('search');
-		self::_testPagination('tag');
-	}
-	
-	
-	private function _createPaginationData($objectType, $num) {
-		switch ($objectType) {
-		case 'collection':                                                                                         
-			for ($i=0; $i<$num; $i++) {
-				API::createCollection("Test", false, $this, 'key');
-			}
-			break;
-		
-		case 'item':
-			for ($i=0; $i<$num; $i++) {
-				API::createItem("book", false, $this, 'key');
-			}
-			break;
-		
-		case 'search':
-			for ($i=0; $i<$num; $i++) {
-				API::createSearch("Test", 'default', $this, 'key');
-			}
-			break;
-		
-		case 'tag':
-			API::createItem("book", [
-				'tags' => [
-					'a',
-					'b'
-				]
-			], $this);
-			API::createItem("book", [
-				'tags' => [
-					'c',
-					'd',
-					'e'
-				]
-			], $this);
-			break;
-		}
-	}
-	
-	
-	private function _testPagination($objectType) {
-		API::userClear(self::$config['userID']);
-		
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$limit = 2;
-		$totalResults = 5;
-		$formats = ['json', 'atom', 'keys'];
-		
-		// Create sample data
-		switch ($objectType) {
-		case 'collection':
-		case 'item':
-		case 'search':
-		case 'tag':
-			$this->_createPaginationData($objectType, $totalResults);
-			break;
-		}
-		
-		switch ($objectType) {
-		case 'item':
-			array_push($formats, 'bibtex');
-			break;
-			
-		case 'tag':
-			$formats = array_filter($formats, function ($val) { return !in_array($val, ['keys']); });
-			break;
-		
-		case 'group':
-			// Change if the config changes
-			$limit = 1;
-			$totalResults = self::$config['numOwnedGroups'];
-			$formats = array_filter($formats, function ($val) { return !in_array($val, ['keys']); });
-			break;
-		}
-		
-		foreach ($formats as $format) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"$objectTypePlural?limit=$limit&format=$format"
-			);
-			$this->assert200($response);
-			$this->assertNumResults($limit, $response);
-			$this->assertTotalResults($totalResults, $response);
-			$links = $this->parseLinkHeader($response->getHeader('Link'));
-			$this->assertArrayNotHasKey('first', $links);
-			$this->assertArrayNotHasKey('prev', $links);
-			$this->assertArrayHasKey('next', $links);
-			$this->assertEquals($limit, $links['next']['params']['start']);
-			$this->assertEquals($limit, $links['next']['params']['limit']);
-			$this->assertArrayHasKey('last', $links);
-			$lastStart = $totalResults - ($totalResults % $limit);
-			if ($lastStart == $totalResults) {
-				$lastStart -= $limit;
-			}
-			$this->assertEquals($lastStart, $links['last']['params']['start']);
-			$this->assertEquals($limit, $links['last']['params']['limit']);
-			
-			// TODO: Test with more groups
-			if ($objectType == 'group') continue;
-			
-			// Start at 1
-			$start = 1;
-			$response = API::userGet(
-				self::$config['userID'],
-				"$objectTypePlural?start=$start&limit=$limit&format=$format"
-			);
-			$this->assert200($response);
-			$this->assertNumResults($limit, $response);
-			$this->assertTotalResults($totalResults, $response);
-			$links = $this->parseLinkHeader($response->getHeader('Link'));
-			$this->assertArrayHasKey('first', $links);
-			$this->assertArrayNotHasKey('start', $links['first']['params']);
-			$this->assertEquals($limit, $links['first']['params']['limit']);
-			$this->assertArrayHasKey('prev', $links);
-			$this->assertArrayNotHasKey('start', $links['prev']['params']);
-			$this->assertEquals($limit, $links['prev']['params']['limit']);
-			$this->assertArrayHasKey('next', $links);
-			$this->assertEquals($start + $limit, $links['next']['params']['start']);
-			$this->assertEquals($limit, $links['next']['params']['limit']);
-			$this->assertArrayHasKey('last', $links);
-			$lastStart = $totalResults - ($totalResults % $limit);
-			if ($lastStart == $totalResults) {
-				$lastStart -= $limit;
-			}
-			$this->assertEquals($lastStart, $links['last']['params']['start']);
-			$this->assertEquals($limit, $links['last']['params']['limit']);
-			
-			// Start at 2
-			$start = 2;
-			$response = API::userGet(
-				self::$config['userID'],
-				"$objectTypePlural?start=$start&limit=$limit&format=$format"
-			);
-			$this->assert200($response);
-			$this->assertNumResults($limit, $response);
-			$this->assertTotalResults($totalResults, $response);
-			$links = $this->parseLinkHeader($response->getHeader('Link'));
-			$this->assertArrayHasKey('first', $links);
-			$this->assertArrayNotHasKey('start', $links['first']['params']);
-			$this->assertEquals($limit, $links['first']['params']['limit']);
-			$this->assertArrayHasKey('prev', $links);
-			$this->assertArrayNotHasKey('start', $links['prev']['params']);
-			$this->assertEquals($limit, $links['prev']['params']['limit']);
-			$this->assertArrayHasKey('next', $links);
-			$this->assertEquals($start + $limit, $links['next']['params']['start']);
-			$this->assertEquals($limit, $links['next']['params']['limit']);
-			$this->assertArrayHasKey('last', $links);
-			$lastStart = $totalResults - ($totalResults % $limit);
-			if ($lastStart == $totalResults) {
-				$lastStart -= $limit;
-			}
-			$this->assertEquals($lastStart, $links['last']['params']['start']);
-			$this->assertEquals($limit, $links['last']['params']['limit']);
-			
-			// Start at 3
-			$start = 3;
-			$response = API::userGet(
-				self::$config['userID'],
-				"$objectTypePlural?start=$start&limit=$limit&format=$format"
-			);
-			$this->assert200($response);
-			$this->assertNumResults($limit, $response);
-			$this->assertTotalResults($totalResults, $response);
-			$links = $this->parseLinkHeader($response->getHeader('Link'));
-			$this->assertArrayHasKey('first', $links);
-			$this->assertArrayNotHasKey('start', $links['first']['params']);
-			$this->assertEquals($limit, $links['first']['params']['limit']);
-			$this->assertArrayHasKey('prev', $links);
-			$this->assertEquals(max(0, $start - $limit), $links['prev']['params']['start']);
-			$this->assertEquals($limit, $links['prev']['params']['limit']);
-			$this->assertArrayNotHasKey('next', $links);
-			$this->assertArrayHasKey('last', $links);
-			$lastStart = $totalResults - ($totalResults % $limit);
-			if ($lastStart == $totalResults) {
-				$lastStart -= $limit;
-			}
-			$this->assertEquals($lastStart, $links['last']['params']['start']);
-			$this->assertEquals($limit, $links['last']['params']['limit']);
-		}
-	}
-	
-	
-	// Test disabled because it's slow
-	/*public function testPaginationWithItemKey() {
-		$totalResults = 27;
-		
-		for ($i=0; $i<$totalResults; $i++) {
-			API::createItem("book", false, $this, 'key');
-		}
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&limit=50"
-		);
-		$keys = explode("\n", trim($response->getBody()));
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=json&itemKey=" . join(",", $keys)
-		);
-		$json = API::getJSONFromResponse($response);;
-		$this->assertCount($totalResults, $json);
-	}*/
-	
-	
-	public function testCollectionQuickSearch() {
-		$title1 = "Test Title";
-		$title2 = "Another Title";
-		
-		$keys = [];
-		$keys[] = API::createCollection($title1, [], $this, 'key');
-		$keys[] = API::createCollection($title2, [], $this, 'key');
-		
-		// Search by title
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections?q=another"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($keys[1], $json[0]['key']);
-		
-		// No results
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections?q=nothing"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-	}
-	
-	
-	public function testItemQuickSearch() {
-		$title1 = "Test Title";
-		$title2 = "Another Title";
-		$year2 = "2013";
-		
-		$keys = [];
-		$keys[] = API::createItem("book", [
-			'title' => $title1
-		], $this, 'key');
-		$keys[] = API::createItem("journalArticle", [
-			'title' => $title2,
-			'date' => "November 25, $year2"
-		], $this, 'key');
-		
-		// Search by title
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=" . urlencode($title1)
-		);
-		$this->assert200($response);                                                                         
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($keys[0], $json[0]['key']);
-		
-		// TODO: Search by creator
-		
-		// Search by year
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=$year2"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($keys[1], $json[0]['key']);
-		
-		// Search by year + 1
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=" . ($year2 + 1)
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-	}
-	
-	
-	public function testItemQuickSearchOrderByDate() {
-		$title1 = "Test Title";
-		$title2 = "Another Title";
-		
-		$keys = [];
-		$keys[] = API::createItem("book", [
-			'title' => $title1,
-			'date' => "February 12, 2013"
-		], $this, 'key');
-		$keys[] = API::createItem("journalArticle", [
-			'title' => $title2,
-			'date' => "November 25, 2012"
-		], $this, 'key');
-		
-		// Search for one by title
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=" . urlencode($title1)
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($keys[0], $json[0]['key']);
-		
-		// Search by both by title, date asc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=title&sort=date&direction=asc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(2, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($keys[1], $json[0]['key']);
-		$this->assertEquals($keys[0], $json[1]['key']);
-		
-		// Search by both by title, date asc, with old-style parameters
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=title&order=date&sort=asc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(2, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($keys[1], $json[0]['key']);
-		$this->assertEquals($keys[0], $json[1]['key']);
-		
-		// Search by both by title, date desc
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=title&sort=date&direction=desc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(2, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($keys[0], $json[0]['key']);
-		$this->assertEquals($keys[1], $json[1]['key']);
-		
-		// Search by both by title, date desc, with old-style parameters
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?q=title&order=date&sort=desc"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(2, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($keys[0], $json[0]['key']);
-		$this->assertEquals($keys[1], $json[1]['key']);
-	}
-	
-	
-	private function parseLinkHeader($links) {
-		$this->assertNotNull($links);
-		$links = explode(',', $links);
-		$parsedLinks = [];
-		foreach  ($links as $link) {
-			list($uri, $rel) = explode('; ', trim($link));
-			$this->assertRegExp('/^<https?:\/\/[^ ]+>$/', $uri);
-			$this->assertRegExp('/^rel="[a-z]+"$/', $rel);
-			$uri = substr($uri, 1, -1);
-			$rel = substr($rel, strlen('rel="'), -1);
-			
-			parse_str(parse_url($uri, PHP_URL_QUERY), $params);
-			$parsedLinks[$rel] = [
-				'uri' => $uri,
-				'params' => $params
-			];
-		}
-		return $parsedLinks;
-	}
-}
diff --git a/tests/remote/tests/API/3/PermissionsTest.php b/tests/remote/tests/API/3/PermissionsTest.php
deleted file mode 100644
index 0adf8a02..00000000
--- a/tests/remote/tests/API/3/PermissionsTest.php
+++ /dev/null
@@ -1,425 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class PermissionsTest extends APITests {
-	private static $publicGroupID;
-	
-	public function setUp() {
-		parent::setUp();
-		API::resetKey(self::$config['apiKey']);
-		API::setKeyUserPermission(self::$config['apiKey'], 'library', true);
-		API::setKeyUserPermission(self::$config['apiKey'], 'write', true);
-		API::setKeyGroupPermission(self::$config['apiKey'], 0, 'write', true);
-	}
-	
-	
-	public function testUserGroupsAnonymousJSON() {
-		API::useAPIKey(false);
-		$response = API::get("users/" . self::$config['userID'] . "/groups");
-		$this->assert200($response);
-		
-		$this->assertTotalResults(self::$config['numPublicGroups'], $response);
-		
-		// Make sure they're the right groups
-		$json = API::getJSONFromResponse($response);
-		$groupIDs = array_map(function ($data) {
-			return $data['id'];
-		}, $json);
-		$this->assertContains(self::$config['ownedPublicGroupID'], $groupIDs);
-		$this->assertContains(self::$config['ownedPublicNoAnonymousGroupID'], $groupIDs);
-	}
-	
-	
-	public function testUserGroupsAnonymousAtom() {
-		API::useAPIKey(false);
-		$response = API::get("users/" . self::$config['userID'] . "/groups?content=json");
-		$this->assert200($response);
-		
-		$this->assertTotalResults(self::$config['numPublicGroups'], $response);
-		
-		// Make sure they're the right groups
-		$xml = API::getXMLFromResponse($response);
-		$groupIDs = array_map(function ($id) {
-			return (int) $id;
-		}, $xml->xpath('//atom:entry/zapi:groupID'));
-		$this->assertContains(self::$config['ownedPublicGroupID'], $groupIDs);
-		$this->assertContains(self::$config['ownedPublicNoAnonymousGroupID'], $groupIDs);
-	}
-	
-	
-	public function testUserGroupsOwned() {
-		API::useAPIKey(self::$config['apiKey']);
-		$response = API::userGet(self::$config['userID'], "groups");
-		$this->assert200($response);
-		
-		$this->assertNumResults(self::$config['numOwnedGroups'], $response);
-		$this->assertTotalResults(self::$config['numOwnedGroups'], $response);
-	}
-	
-	
-	public function test_should_see_private_group_listed_when_using_key_with_library_read_access() {
-		API::resetKey(self::$config['apiKey']);
-		$response = API::userGet(self::$config['userID'], "groups");
-		$this->assert200($response);
-		
-		$this->assertNumResults(self::$config['numPublicGroups'], $response);
-		
-		// Grant key read permission to library
-		API::setKeyGroupPermission(
-			self::$config['apiKey'],
-			self::$config['ownedPrivateGroupID'],
-			'library',
-			true
-		);
-		
-		$response = API::userGet(self::$config['userID'], "groups");
-		$this->assertNumResults(self::$config['numOwnedGroups'], $response);
-		$this->assertTotalResults(self::$config['numOwnedGroups'], $response);
-		
-		$json = API::getJSONFromResponse($response);
-		$groupIDs = array_map(function ($data) {
-			return $data['id'];
-		}, $json);
-		$this->assertContains(self::$config['ownedPrivateGroupID'], $groupIDs);
-	}
-	
-	
-	public function testGroupLibraryReading() {
-		$groupID = self::$config['ownedPublicNoAnonymousGroupID'];
-		API::groupClear($groupID);
-		
-		$json = API::groupCreateItem(
-			$groupID,
-			'book',
-			[
-				'title' => "Test"
-			],
-			$this
-		);
-		
-		try {
-			API::useAPIKey(self::$config['apiKey']);
-			$response = API::groupGet($groupID, "items");
-			$this->assert200($response);
-			$this->assertNumResults(1, $response);
-			
-			// An anonymous request should fail, because libraryReading is members
-			API::useAPIKey(false);
-			$response = API::groupGet($groupID, "items");
-			$this->assert403($response);
-		}
-		finally {
-			API::groupClear($groupID);
-		}
-	}
-	
-	
-	public function test_shouldnt_be_able_to_write_to_group_using_key_with_library_read_access() {
-		API::resetKey(self::$config['apiKey']);
-		
-		// Grant key read (not write) permission to library
-		API::setKeyGroupPermission(
-			self::$config['apiKey'],
-			self::$config['ownedPrivateGroupID'],
-			'library',
-			true
-		);
-		
-		$response = API::get("items/new?itemType=book");
-		$json = json_decode($response->getBody(), true);
-		
-		$response = API::groupPost(
-			self::$config['ownedPrivateGroupID'],
-			"items",
-			json_encode([
-				"items" => [$json]
-			]),
-			["Content-Type: application/json"]
-		);
-		$this->assert403($response);
-	}
-	
-	
-	/**
-	 * A key without note access shouldn't be able to create a note
-	 */
-	/*public function testKeyNoteAccessWriteError() {
-		API::setKeyUserPermission(self::$config['apiKey'], 'notes', false);
-		
-		$response = API::get("items/new?itemType=note");
-		$json = json_decode($response->getBody());
-		$json->note = "Test";
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode(array(
-				"items" => array($json)
-			)),
-			array("Content-Type: application/json")
-		);
-		$this->assert403($response);
-	}*/
-	
-	
-	public function testKeyNoteAccess() {
-		API::userClear(self::$config['userID']);
-		
-		API::setKeyUserPermission(self::$config['apiKey'], 'notes', true);
-		
-		$keys = array();
-		$topLevelKeys = array();
-		$bookKeys = array();
-		
-		$key = API::createItem('book', array("title" => "A"), $this, 'key');
-		$keys[] = $key;
-		$topKeys[] = $key;
-		$bookKeys[] = $key;
-		
-		$key = API::createNoteItem("B", false, $this, 'key');
-		$keys[] = $key;
-		$topKeys[] = $key;
-		
-		$key = API::createNoteItem("C", false, $this, 'key');
-		$keys[] = $key;
-		$topKeys[] = $key;
-		
-		$key = API::createNoteItem("D", false, $this, 'key');
-		$keys[] = $key;
-		$topKeys[] = $key;
-		
-		$key = API::createNoteItem("E", false, $this, 'key');
-		$keys[] = $key;
-		$topKeys[] = $key;
-		
-		$key = API::createItem('book', array("title" => "F"), $this, 'key');
-		$keys[] = $key;
-		$topKeys[] = $key;
-		$bookKeys[] = $key;
-		
-		$key = API::createNoteItem("G", $key, $this, 'key');
-		$keys[] = $key;
-		
-		// Create collection and add items to it
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections",
-			json_encode([
-				[
-					"name" => "Test",
-					"parentCollection" => false
-				]
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert200ForObject($response);
-		$collectionKey = API::getFirstSuccessKeyFromResponse($response);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections/$collectionKey/items",
-			implode(" ", $topKeys)
-		);
-		$this->assert204($response);
-		
-		//
-		// format=atom
-		//
-		// Root
-		$response = API::userGet(
-			self::$config['userID'], "items"
-		);
-		$this->assertNumResults(sizeOf($keys), $response);
-		$this->assertTotalResults(sizeOf($keys), $response);
-		
-		// Top
-		$response = API::userGet(
-			self::$config['userID'], "items/top"
-		);
-		$this->assertNumResults(sizeOf($topKeys), $response);
-		$this->assertTotalResults(sizeOf($topKeys), $response);
-		
-		// Collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top"
-		);
-		$this->assertNumResults(sizeOf($topKeys), $response);
-		$this->assertTotalResults(sizeOf($topKeys), $response);
-		
-		//
-		// format=keys
-		//
-		// Root
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys"
-		);
-		$this->assert200($response);
-		$this->assertCount(sizeOf($keys), explode("\n", trim($response->getBody())));
-		
-		// Top
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?format=keys"
-		);
-		$this->assert200($response);
-		$this->assertCount(sizeOf($topKeys), explode("\n", trim($response->getBody())));
-		
-		// Collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items/top?format=keys"
-		);
-		$this->assert200($response);
-		$this->assertCount(sizeOf($topKeys), explode("\n", trim($response->getBody())));
-		
-		// Remove notes privilege from key
-		API::setKeyUserPermission(self::$config['apiKey'], 'notes', false);
-		
-		//
-		// format=json
-		//
-		// totalResults with limit
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?limit=1"
-		);
-		$this->assertNumResults(1, $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		// And without limit
-		$response = API::userGet(
-			self::$config['userID'],
-			"items"
-		);
-		$this->assertNumResults(sizeOf($bookKeys), $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		// Top
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top"
-		);
-		$this->assertNumResults(sizeOf($bookKeys), $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		// Collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items"
-		);
-		$this->assertNumResults(sizeOf($bookKeys), $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		//
-		// format=atom
-		//
-		// totalResults with limit
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=atom&limit=1"
-		);
-		$this->assertNumResults(1, $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		// And without limit
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=atom"
-		);
-		$this->assertNumResults(sizeOf($bookKeys), $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		// Top
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?format=atom"
-		);
-		$this->assertNumResults(sizeOf($bookKeys), $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		// Collection
-		$response = API::userGet(
-			self::$config['userID'],
-			"collections/$collectionKey/items?format=atom"
-		);
-		$this->assertNumResults(sizeOf($bookKeys), $response);
-		$this->assertTotalResults(sizeOf($bookKeys), $response);
-		
-		//
-		// format=keys
-		//
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys"
-		);
-		$keys = explode("\n", trim($response->getBody()));
-		sort($keys);
-		$this->assertEmpty(
-			array_merge(
-				array_diff($bookKeys, $keys), array_diff($keys, $bookKeys)
-			)
-		);
-	}
-	
-	
-	public function testTagDeletePermissions() {
-		API::userClear(self::$config['userID']);
-		
-		API::createItem('book', array(
-			"tags" => array(
-				array(
-					"tag" => "A"
-				)
-			)
-		), $this);
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		API::setKeyUserPermission(self::$config['apiKey'], 'write', false);
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?tag=A&key=" . self::$config['apiKey']
-		);
-		$this->assert403($response);
-		
-		API::setKeyUserPermission(self::$config['apiKey'], 'write', true);
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?tag=A&key=" . self::$config['apiKey'],
-			array("If-Unmodified-Since-Version: $libraryVersion")
-		);
-		$this->assert204($response);
-	}
-}
diff --git a/tests/remote/tests/API/3/PublicationsTest.php b/tests/remote/tests/API/3/PublicationsTest.php
deleted file mode 100644
index f16283a3..00000000
--- a/tests/remote/tests/API/3/PublicationsTest.php
+++ /dev/null
@@ -1,927 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2015 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 *****
-*/
-
-namespace APIv3;
-use API3 as API, HTTP, Z_Tests;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-/**
- * @group s3
- */
-class PublicationsTests extends APITests {
-	private static $toDelete = [];
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		
-		$s3Client = Z_Tests::$AWS->createS3();
-		foreach (self::$toDelete as $file) {
-			try {
-				$s3Client->deleteObject([
-					'Bucket' => self::$config['s3Bucket'],
-					'Key' => $file
-				]);
-			}
-			catch (\Aws\S3\Exception\S3Exception $e) {
-				if ($e->getAwsErrorCode() == 'NoSuchKey') {
-					echo "\n$file not found on S3 to delete\n";
-				}
-				else {
-					throw $e;
-				}
-			}
-		}
-		
-		API::userClear(self::$config['userID']);
-	}
-	
-	public function setUp() {
-		parent::setUp();
-		
-		API::userClear(self::$config['userID']);
-		
-		// Default to anonymous requests
-		API::useAPIKey("");
-	}
-	
-	
-	//
-	// Test read requests for empty publications list
-	//
-	public function test_should_return_no_results_for_empty_publications_list() {
-		$response = API::get("users/" . self::$config['userID'] . "/publications/items");
-		$this->assert200($response);
-		$this->assertNoResults($response);
-		$this->assertInternalType("numeric", $response->getHeader('Last-Modified-Version'));
-	}
-	
-	
-	public function test_should_return_no_results_for_empty_publications_list_with_key() {
-		API::useAPIKey(self::$config['apiKey']);
-		$response = API::get("users/" . self::$config['userID'] . "/publications/items");
-		$this->assert200($response);
-		$this->assertNoResults($response);
-		$this->assertInternalType("numeric", $response->getHeader('Last-Modified-Version'));
-	}
-	
-	
-	public function test_should_return_no_atom_results_for_empty_publications_list() {
-		$response = API::get("users/" . self::$config['userID'] . "/publications/items?format=atom");
-		$this->assert200($response);
-		$this->assertNoResults($response);
-		$this->assertInternalType("numeric", $response->getHeader('Last-Modified-Version'));
-	}
-	
-	
-	// Disabled until it works
-	/*public function test_should_return_304_for_request_with_etag() {
-		$response = API::get("users/" . self::$config['userID'] . "/publications/items");
-		$this->assert200($response);
-		$etag = $response->getHeader("ETag");
-		$this->assertNotNull($etag);
-		
-		// Repeat request with ETag
-		$response = API::get(
-			"users/" . self::$config['userID'] . "/publications/items",
-			[
-				"If-None-Match: $etag"
-			]
-		);
-		$this->assert304($response);
-		$this->assertEquals($etag, $response->getHeader("ETag"));
-	}*/
-	
-	
-	// Disabled until after integrated My Publications upgrade
-	/*public function test_should_return_404_for_settings_request() {
-		$response = API::get("users/" . self::$config['userID'] . "/publications/settings");
-		$this->assert404($response);
-	}*/
-	public function test_should_return_200_for_settings_request_with_no_items() {
-		$response = API::get("users/" . self::$config['userID'] . "/publications/settings");
-		$this->assert200($response);
-		$this->assertNoResults($response);
-	}
-	public function test_should_return_400_for_settings_request_with_items() {
-		API::useAPIKey(self::$config['apiKey']);
-		$response = API::createItem("book", ['inPublications' => true], $this, 'response');
-		$this->assert200ForObject($response);
-		
-		$response = API::get("users/" . self::$config['userID'] . "/publications/settings");
-		$this->assert400($response);
-	}
-	
-	// Disabled until after integrated My Publications upgrade
-	/*public function test_should_return_404_for_deleted_request() {
-		$response = API::get("users/" . self::$config['userID'] . "/publications/deleted?since=0");
-		$this->assert404($response);
-	}*/
-	public function test_should_return_200_for_deleted_request() {
-		$response = API::get("users/" . self::$config['userID'] . "/publications/deleted?since=0");
-		$this->assert200($response);
-	}
-	
-	public function test_should_return_404_for_collections_request() {
-		$response = API::get("users/" . self::$config['userID'] . "/publications/collections");
-		$this->assert404($response);
-	}
-	
-	
-	public function test_should_return_404_for_searches_request() {
-		$response = API::get("users/" . self::$config['userID'] . "/publications/searches");
-		$this->assert404($response);
-	}
-	
-	
-	public function test_should_return_403_for_anonymous_write() {
-		$json = API::getItemTemplate("book");
-		$response = API::userPost(
-			self::$config['userID'],
-			"publications/items",
-			json_encode($json)
-		);
-		$this->assert403($response);
-	}
-	
-	
-	public function test_should_return_405_for_authenticated_write() {
-		API::useAPIKey(self::$config['apiKey']);
-		$json = API::getItemTemplate("book");
-		$response = API::userPost(
-			self::$config['userID'],
-			"publications/items",
-			json_encode($json)
-		);
-		$this->assert405($response);
-	}
-	
-	
-	public function test_should_return_404_for_anonymous_request_for_item_not_in_publications() {
-		// Create item
-		API::useAPIKey(self::$config['apiKey']);
-		$key = API::createItem("book", [], $this, 'key');
-		
-		// Fetch anonymously
-		API::useAPIKey();
-		$response = API::get("users/" . self::$config['userID'] . "/publications/items/$key");
-		$this->assert404($response);
-	}
-	
-	
-	public function test_should_return_404_for_authenticated_request_for_item_not_in_publications() {
-		// Create item
-		API::useAPIKey(self::$config['apiKey']);
-		$key = API::createItem("book", [], $this, 'key');
-		
-		// Fetch anonymously
-		$response = API::get("users/" . self::$config['userID'] . "/publications/items/$key");
-		$this->assert404($response);
-	}
-	
-	
-	public function test_should_trigger_notification_on_publications_topic() {
-		// Create item
-		API::useAPIKey(self::$config['apiKey']);
-		$response = API::createItem("book", ['inPublications' => true], $this, 'response');
-		$version = API::getJSONFromResponse($response)['successful'][0]['version'];
-		
-		// Test notification for publications topic (in addition to regular library)
-		$this->assertCountNotifications(2, $response);
-		$this->assertHasNotification([
-			'event' => 'topicUpdated',
-			'topic' => '/users/' . self::$config['userID'],
-			'version' => $version
-		], $response);
-		$this->assertHasNotification([
-			'event' => 'topicUpdated',
-			'topic' => '/users/' . self::$config['userID'] . '/publications'
-		], $response);
-		
-		$json = API::getJSONFromResponse($response);
-	}
-	
-	
-	public function test_should_show_item_for_anonymous_single_object_request() {
-		// Create item
-		API::useAPIKey(self::$config['apiKey']);
-		$itemKey = API::createItem("book", ['inPublications' => true], $this, 'key');
-		
-		// Read item anonymously
-		API::useAPIKey("");
-		
-		// JSON
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/$itemKey"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals(self::$config['username'], $json['library']['name']);
-		$this->assertEquals("user", $json['library']['type']);
-		
-		// Atom
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/$itemKey?format=atom"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(self::$config['username'], (string) $xml->author->name);
-	}
-	
-	
-	public function test_should_show_item_for_anonymous_multi_object_request() {
-		// Create item
-		API::useAPIKey(self::$config['apiKey']);
-		$itemKey = API::createItem("book", ['inPublications' => true], $this, 'key');
-		
-		// Read item anonymously
-		API::useAPIKey("");
-		
-		// JSON
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertContains($itemKey, array_map(function ($item) {
-			return $item['key'];
-		}, $json));
-		
-		// Atom
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items?format=atom"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$xpath = $xml->xpath('//atom:entry/zapi:key');
-		$this->assertContains($itemKey, $xpath);
-	}
-	
-	
-	public function test_shouldnt_show_child_item_not_in_publications() {
-		// Create parent item
-		API::useAPIKey(self::$config['apiKey']);
-		$parentItemKey = API::createItem("book", ['title' => 'A', 'inPublications' => true], $this, 'key');
-		
-		// Create shown child attachment
-		$json1 = API::getItemTemplate("attachment&linkMode=imported_file");
-		$json1->title = 'B';
-		$json1->parentItem = $parentItemKey;
-		$json1->inPublications = true;
-		// Create hidden child attachment
-		$json2 = API::getItemTemplate("attachment&linkMode=imported_file");
-		$json2->title = 'C';
-		$json2->parentItem = $parentItemKey;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json1, $json2])
-		);
-		$this->assert200($response);
-		
-		// Anonymous read
-		API::useAPIKey("");
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json);
-		$titles = array_map(function ($item) {
-			return $item['data']['title'];
-		}, $json);
-		$this->assertContains('A', $titles);
-		$this->assertContains('B', $titles);
-		$this->assertNotContains('C', $titles);
-	}
-	
-	
-	public function test_shouldnt_show_child_item_not_in_publications_for_item_children_request() {
-		// Create parent item
-		API::useAPIKey(self::$config['apiKey']);
-		$parentItemKey = API::createItem("book", ['title' => 'A', 'inPublications' => true], $this, 'key');
-		
-		// Create shown child attachment
-		$json1 = API::getItemTemplate("attachment&linkMode=imported_file");
-		$json1->title = 'B';
-		$json1->parentItem = $parentItemKey;
-		$json1->inPublications = true;
-		// Create hidden child attachment
-		$json2 = API::getItemTemplate("attachment&linkMode=imported_file");
-		$json2->title = 'C';
-		$json2->parentItem = $parentItemKey;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json1, $json2])
-		);
-		$this->assert200($response);
-		
-		// Anonymous read
-		API::useAPIKey("");
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/$parentItemKey/children"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(1, $json);
-		$titles = array_map(function ($item) {
-			return $item['data']['title'];
-		}, $json);
-		$this->assertContains('B', $titles);
-	}
-	
-	
-	public function test_shouldnt_include_hidden_child_items_in_numChildren() {
-		// Create parent item
-		API::useAPIKey(self::$config['apiKey']);
-		$parentItemKey = API::createItem("book", ['inPublications' => true], $this, 'key');
-		
-		// Create shown child attachment
-		$json1 = API::getItemTemplate("attachment&linkMode=imported_file");
-		$json1->title = 'A';
-		$json1->parentItem = $parentItemKey;
-		$json1->inPublications = true;
-		// Create shown child note
-		$json2 = API::getItemTemplate("note");
-		$json2->note = 'B';
-		$json2->parentItem = $parentItemKey;
-		$json2->inPublications = true;
-		// Create hidden child attachment
-		$json3 = API::getItemTemplate("attachment&linkMode=imported_file");
-		$json3->title = 'C';
-		$json3->parentItem = $parentItemKey;
-		// Create deleted child attachment
-		$json4 = API::getItemTemplate("note");
-		$json4->note = 'D';
-		$json4->parentItem = $parentItemKey;
-		$json4->inPublications = true;
-		$json4->deleted = true;
-		// Create hidden deleted child attachment
-		$json5 = API::getItemTemplate("attachment&linkMode=imported_file");
-		$json5->title = 'E';
-		$json5->parentItem = $parentItemKey;
-		$json5->deleted = true;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json1, $json2, $json3, $json4, $json5])
-		);
-		$this->assert200($response);
-		
-		// Anonymous read
-		API::useAPIKey("");
-		
-		// JSON
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/$parentItemKey"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals(2, $json['meta']['numChildren']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/$parentItemKey/children"
-		);
-		
-		// Atom
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/$parentItemKey?format=atom"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(2, (int) array_get_first($xml->xpath('/atom:entry/zapi:numChildren')));
-	}
-	
-	
-	public function test_should_include_download_details() {
-		$file = "work/file";
-		$fileContents = self::getRandomUnicodeString();
-		$contentType = "text/html";
-		$charset = "utf-8";
-		file_put_contents($file, $fileContents);
-		$hash = md5_file($file);
-		$filename = "test_" . $fileContents;
-		$mtime = filemtime($file) * 1000;
-		$size = filesize($file);
-		
-		$parentItemKey = API::createItem("book", ['title' => 'A', 'inPublications' => true], $this, 'key');
-		$json = API::createAttachmentItem("imported_file", [
-			'parentItem' => $parentItemKey,
-			'inPublications' => true,
-			'contentType' => $contentType,
-			'charset' => $charset
-		], false, $this, 'jsonData');
-		$key = $json['key'];
-		$originalVersion = $json['version'];
-		
-		//
-		// Get upload authorization
-		//
-		API::useAPIKey(self::$config['apiKey']);
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			$this->implodeParams([
-				"md5" => $hash,
-				"mtime" => $mtime,
-				"filename" => $filename,
-				"filesize" => $size
-			]),
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		self::$toDelete[] = "$hash";
-		
-		//
-		// Upload to S3
-		//
-		$response = HTTP::post(
-			$json['url'],
-			$json['prefix'] . $fileContents . $json['suffix'],
-			[
-				"Content-Type: {$json['contentType']}"
-			]
-		);
-		$this->assert201($response);
-		
-		//
-		// Register upload
-		//
-		$response = API::userPost(
-			self::$config['userID'],
-			"items/$key/file",
-			"upload=" . $json['uploadKey'],
-			[
-				"Content-Type: application/x-www-form-urlencoded",
-				"If-None-Match: *"
-			]
-		);
-		$this->assert204($response);
-		$newVersion = $response->getHeader('Last-Modified-Version');
-		$this->assertGreaterThan($originalVersion, $newVersion);
-		
-		// Anonymous read
-		API::useAPIKey("");
-		
-		// Verify attachment item metadata (JSON)
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/$key"
-		);
-		$json = API::getJSONFromResponse($response);
-		$jsonData = $json['data'];
-		$this->assertEquals($hash, $jsonData['md5']);
-		$this->assertEquals($mtime, $jsonData['mtime']);
-		$this->assertEquals($filename, $jsonData['filename']);
-		$this->assertEquals($contentType, $jsonData['contentType']);
-		$this->assertEquals($charset, $jsonData['charset']);
-		
-		// Verify download details (JSON)
-		$this->assertRegExp(
-			"%https?://[^/]+/users/" . self::$config['userID'] . "/publications/items/$key/file/view%",
-			$json['links']['enclosure']['href']
-		);
-		
-		// Verify attachment item metadata (Atom)
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/$key?format=atom"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$href = (string) array_get_first($xml->xpath('//atom:entry/atom:link[@rel="enclosure"]'))['href'];
-		
-		// Verify download details (JSON)
-		$this->assertRegExp(
-			"%https?://[^/]+/users/" . self::$config['userID'] . "/publications/items/$key/file/view%",
-			$href
-		);
-		
-		// Check access to file
-		preg_match(
-			"%https?://[^/]+/(users/" . self::$config['userID'] . "/publications/items/$key/file/view)%",
-			$href,
-			$matches
-		);
-		$fileURL = $matches[1];
-		$response = API::get($fileURL);
-		$this->assert302($response);
-		
-		// Remove item from My Publications
-		API::useAPIKey(self::$config['apiKey']);
-		
-		$json['data']['inPublications'] = false;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			[
-				"Content-Type" => "application/json"
-			]
-		);
-		$this->assert200ForObject($response);
-		
-		// No more access via publications URL
-		API::useAPIKey();
-		$response = API::get($fileURL);
-		$this->assert404($response);
-	}
-	
-	
-	public function test_shouldnt_show_child_items_in_top_mode() {
-		// Create parent item
-		API::useAPIKey(self::$config['apiKey']);
-		$parentItemKey = API::createItem("book", ['title' => 'A', 'inPublications' => true], $this, 'key');
-		
-		// Create shown child attachment
-		$json1 = API::getItemTemplate("attachment&linkMode=imported_file");
-		$json1->title = 'B';
-		$json1->parentItem = $parentItemKey;
-		$json1->inPublications = true;
-		// Create hidden child attachment
-		$json2 = API::getItemTemplate("attachment&linkMode=imported_file");
-		$json2->title = 'C';
-		$json2->parentItem = $parentItemKey;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json1, $json2])
-		);
-		$this->assert200($response);
-		
-		// Anonymous read
-		API::useAPIKey("");
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/top"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(1, $json);
-		$titles = array_map(function ($item) {
-			return $item['data']['title'];
-		}, $json);
-		$this->assertContains('A', $titles);
-	}
-	
-	
-	public function test_shouldnt_show_trashed_item() {
-		API::useAPIKey(self::$config['apiKey']);
-		$itemKey = API::createItem("book", ['inPublications' => true, 'deleted' => true], $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/$itemKey"
-		);
-		$this->assert404($response);
-	}
-	
-	
-	public function test_shouldnt_show_restricted_properties() {
-		API::useAPIKey(self::$config['apiKey']);
-		$itemKey = API::createItem("book", [ 'inPublications' => true ], $this, 'key');
-		
-		// JSON
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/$itemKey"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertArrayNotHasKey('inPublications', $json['data']);
-		$this->assertArrayNotHasKey('collections', $json['data']);
-		$this->assertArrayNotHasKey('relations', $json['data']);
-		$this->assertArrayNotHasKey('tags', $json['data']);
-		$this->assertArrayNotHasKey('dateAdded', $json['data']);
-		$this->assertArrayNotHasKey('dateModified', $json['data']);
-		
-		// Atom
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items/$itemKey?format=atom&content=html,json"
-		);
-		$this->assert200($response);
-		
-		// HTML in Atom
-		$html = API::getContentFromAtomResponse($response, 'html');
-		$this->assertCount(0, $html->xpath('//html:tr[@class="publications"]'));
-		
-		// JSON in Atom
-		$json = API::getContentFromAtomResponse($response, 'json');
-		$this->assertArrayNotHasKey('inPublications', $json);
-		$this->assertArrayNotHasKey('collections', $json);
-		$this->assertArrayNotHasKey('relations', $json);
-		$this->assertArrayNotHasKey('tags', $json);
-		$this->assertArrayNotHasKey('dateAdded', $json);
-		$this->assertArrayNotHasKey('dateModified', $json);
-	}
-	
-	
-	public function test_shouldnt_show_trashed_item_in_versions_response() {
-		API::useAPIKey(self::$config['apiKey']);
-		$itemKey1 = API::createItem("book", ['inPublications' => true], $this, 'key');
-		$itemKey2 = API::createItem("book", ['inPublications' => true, 'deleted' => true], $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items?format=versions"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertArrayHasKey($itemKey1, $json);
-		$this->assertArrayNotHasKey($itemKey2, $json);
-		
-		// Shouldn't show with includeTrashed=1 here
-		$response = API::userGet(
-			self::$config['userID'],
-			"publications/items?format=versions&includeTrashed=1"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertArrayHasKey($itemKey1, $json);
-		$this->assertArrayNotHasKey($itemKey2, $json);
-	}
-	
-	
-	public function test_should_show_publications_urls_in_json_response_for_single_object_request() {
-		API::useAPIKey(self::$config['apiKey']);
-		$itemKey = API::createItem("book", ['inPublications' => true], $this, 'key');
-		
-		$response = API::get("users/" . self::$config['userID'] . "/publications/items/$itemKey");
-		$json = API::getJSONFromResponse($response);
-		
-		// rel="self"
-		$this->assertRegExp(
-			"%https?://[^/]+/users/" . self::$config['userID'] . "/publications/items/$itemKey%",
-			$json['links']['self']['href']
-		);
-	}
-	
-	
-	public function test_should_show_publications_urls_in_json_response_for_multi_object_request() {
-		API::useAPIKey(self::$config['apiKey']);
-		$itemKey1 = API::createItem("book", ['inPublications' => true], $this, 'key');
-		$itemKey2 = API::createItem("book", ['inPublications' => true], $this, 'key');
-		
-		$response = API::get("users/" . self::$config['userID'] . "/publications/items?limit=1");
-		$json = API::getJSONFromResponse($response);
-		
-		// Parse Link header
-		$links = API::parseLinkHeader($response);
-		
-		// Entry rel="self"
-		$this->assertRegExp(
-			"%https?://[^/]+/users/" . self::$config['userID'] . "/publications/items/($itemKey1|$itemKey2)%",
-			$json[0]['links']['self']['href']
-		);
-		
-		// rel="next"
-		$this->assertRegExp(
-			"%https?://[^/]+/users/" . self::$config['userID'] . "/publications/items%",
-			$links['next']
-		);
-		
-		// TODO: rel="alternate" (what should this be?)
-	}
-	
-	
-	public function test_should_show_publications_urls_in_atom_response_for_single_object_request() {
-		API::useAPIKey(self::$config['apiKey']);
-		$itemKey = API::createItem("book", ['inPublications' => true], $this, 'key');
-		
-		$response = API::get("users/" . self::$config['userID'] . "/publications/items/$itemKey?format=atom");
-		$xml = API::getXMLFromResponse($response);
-		
-		// id
-		$this->assertRegExp(
-			"%http://[^/]+/users/" . self::$config['userID'] . "/items/$itemKey%",
-			(string) ($xml->xpath('//atom:id')[0])
-		);
-		
-		// rel="self"
-		$this->assertRegExp(
-			"%https?://[^/]+/users/" . self::$config['userID'] . "/publications/items/$itemKey\?format=atom%",
-			(string) ($xml->xpath('//atom:link[@rel="self"]')[0]['href'])
-		);
-		
-		// TODO: rel="alternate"
-	}
-	
-	public function test_should_show_publications_urls_in_atom_response_for_multi_object_request() {
-		$response = API::get("users/" . self::$config['userID'] . "/publications/items?format=atom");
-		$xml = API::getXMLFromResponse($response);
-		
-		// id
-		$this->assertRegExp(
-			"%http://[^/]+/users/" . self::$config['userID'] . "/publications/items%",
-			(string) ($xml->xpath('//atom:id')[0])
-		);
-		
-		$this->assertRegExp(
-			"%https?://[^/]+/users/" . self::$config['userID'] . "/publications/items\?format=atom%",
-			(string) ($xml->xpath('//atom:link[@rel="self"]')[0]['href'])
-		);
-		
-		// rel="first"
-		$this->assertRegExp(
-			"%https?://[^/]+/users/" . self::$config['userID'] . "/publications/items\?format=atom%",
-			(string) ($xml->xpath('//atom:link[@rel="first"]')[0]['href'])
-		);
-		
-		// TODO: rel="alternate" (what should this be?)
-	}
-	
-	
-	public function testTopLevelAttachmentAndNote() {
-		$msg = "Top-level notes and attachments cannot be added to My Publications";
-		
-		// Attachment
-		API::useAPIKey(self::$config['apiKey']);
-		$json = API::getItemTemplate("attachment&linkMode=imported_file");
-		$json->inPublications = true;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json])
-		);
-		$this->assert400ForObject($response, $msg, 0);
-		
-		// Note
-		API::useAPIKey(self::$config['apiKey']);
-		$json = API::getItemTemplate("note");
-		$json->inPublications = true;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json])
-		);
-		$this->assert400ForObject($response, $msg, 0);
-	}
-	
-	
-	public function testLinkedFileAttachment() {
-		$msg = "Linked-file attachments cannot be added to My Publications";
-		
-		// Create top-level item
-		API::useAPIKey(self::$config['apiKey']);
-		$json = API::getItemTemplate("book");
-		$json->inPublications = true;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json])
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$itemKey = $json['successful'][0]['key'];
-		
-		$json = API::getItemTemplate("attachment&linkMode=linked_file");
-		$json->inPublications = true;
-		$json->parentItem = $itemKey;
-		API::useAPIKey(self::$config['apiKey']);
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			["Content-Type: application/json"]
-		);
-		$this->assert400ForObject($response, $msg, 0);
-	}
-	
-	
-	public function test_should_remove_inPublications_on_POST_with_false() {
-		API::useAPIKey(self::$config['apiKey']);
-		$json = API::getItemTemplate("book");
-		$json->inPublications = true;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json])
-		);
-		
-		$this->assert200($response);
-		$key = API::getJSONFromResponse($response)['successful'][0]['key'];
-		$version = $response->getHeader("Last-Modified-Version");
-		
-		$json = [
-			"key" => $key,
-			"version" => $version,
-			"title" => "Test",
-			"inPublications" => false
-		];
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			["Content-Type: application/json"]
-		);
-		$this->assert200ForObject($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertArrayNotHasKey('inPublications', $json['successful'][0]['data']);
-	}
-	
-	
-	public function test_shouldnt_remove_inPublications_on_POST_without_property() {
-		API::useAPIKey(self::$config['apiKey']);
-		$json = API::getItemTemplate("book");
-		$json->inPublications = true;
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json])
-		);
-		
-		$this->assert200($response);
-		$key = API::getJSONFromResponse($response)['successful'][0]['key'];
-		$version = $response->getHeader("Last-Modified-Version");
-		
-		$json = [
-			"key" => $key,
-			"version" => $version,
-			"title" => "Test"
-		];
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([$json]),
-			["Content-Type: application/json"]
-		);
-		$this->assert200ForObject($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertTrue($json['successful'][0]['data']['inPublications']);
-	}
-	
-	
-	public function test_shouldnt_allow_inPublications_in_group_library() {
-		API::useAPIKey(self::$config['apiKey']);
-		$json = API::getItemTemplate("book");
-		$json->inPublications = true;
-		$response = API::groupPost(
-			self::$config['ownedPrivateGroupID'],
-			"items",
-			json_encode([$json])
-		);
-		
-		$this->assert400ForObject($response, "Group items cannot be added to My Publications");
-	}
-	
-	
-	private function implodeParams($params, $exclude=array()) {
-		$parts = array();
-		foreach ($params as $key => $val) {
-			if (in_array($key, $exclude)) {
-				continue;
-			}
-			$parts[] = $key . "=" . urlencode($val);
-		}
-		return implode("&", $parts);
-	}
-	
-	private function getRandomUnicodeString() {
-		return "Âéìøü 这是一个测试。 " . uniqid();
-	}
-}
diff --git a/tests/remote/tests/API/3/RelationTest.php b/tests/remote/tests/API/3/RelationTest.php
deleted file mode 100644
index fe8746de..00000000
--- a/tests/remote/tests/API/3/RelationTest.php
+++ /dev/null
@@ -1,416 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class RelationTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testNewItemRelations() {
-		$relations = array(
-			"owl:sameAs" => "http://zotero.org/groups/1/items/AAAAAAAA",
-			"dc:relation" => array(
-				"http://zotero.org/users/" . self::$config['userID'] . "/items/AAAAAAAA",
-				"http://zotero.org/users/" . self::$config['userID'] . "/items/BBBBBBBB",
-			)
-		);
-		
-		$json = API::createItem("book", array(
-			"relations" => $relations
-		), $this, 'jsonData');
-		$this->assertCount(sizeOf($relations), $json['relations']);
-		foreach ($relations as $predicate => $object) {
-			if (is_string($object)) {
-				$this->assertEquals($object, $json['relations'][$predicate]);
-			}
-			else {
-				foreach ($object as $rel) {
-					$this->assertContains($rel, $json['relations'][$predicate]);
-				}
-			}
-		}
-	}
-	
-	
-	public function testRelatedItemRelations() {
-		$relations = [
-			"owl:sameAs" => "http://zotero.org/groups/1/items/AAAAAAAA",
-		];
-		
-		$item1JSON = API::createItem("book", [
-			"relations" => $relations
-		], $this, 'jsonData');
-		$item2JSON = API::createItem("book", null, $this, 'jsonData');
-		
-		$uriPrefix = "http://zotero.org/users/" . self::$config['userID'] . "/items/";
-		$item1URI = $uriPrefix . $item1JSON['key'];
-		$item2URI = $uriPrefix . $item2JSON['key'];
-		
-		// Add item 2 as related item of item 1
-		$relations["dc:relation"] = $item2URI;
-		$item1JSON["relations"] = $relations;
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$item1JSON['key']}",
-			json_encode($item1JSON)
-		);
-		$this->assert204($response);
-		
-		// Make sure it exists on item 1
-		$json = API::getItem($item1JSON['key'], $this, 'json')['data'];
-		$this->assertCount(sizeOf($relations), $json['relations']);
-		foreach ($relations as $predicate => $object) {
-			$this->assertEquals($object, $json['relations'][$predicate]);
-		}
-		
-		// And item 2, since related items are bidirectional
-		$item2JSON = API::getItem($item2JSON['key'], $this, 'json')['data'];
-		$this->assertCount(1, $item2JSON['relations']);
-		$this->assertEquals($item1URI, $item2JSON["relations"]["dc:relation"]);
-		
-		// Sending item 2's unmodified JSON back up shouldn't cause the item to be updated.
-		// Even though we're sending a relation that's technically not part of the item,
-		// when it loads the item it will load the reverse relations too and therefore not
-		// add a relation that it thinks already exists.
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/{$item2JSON['key']}",
-			json_encode($item2JSON)
-		);
-		$this->assert204($response);
-		$this->assertEquals($item2JSON['version'], $response->getHeader("Last-Modified-Version"));
-	}
-	
-	
-	// Same as above, but in a single request
-	public function testRelatedItemRelationsSingleRequest() {
-		$uriPrefix = "http://zotero.org/users/" . self::$config['userID'] . "/items/";
-		// TEMP: Use autoloader
-		require_once '../../model/ID.inc.php';
-		$item1Key = \Zotero_ID::getKey();
-		$item2Key = \Zotero_ID::getKey();
-		$item1URI = $uriPrefix . $item1Key;
-		$item2URI = $uriPrefix . $item2Key;
-		
-		$item1JSON = API::getItemTemplate('book');
-		$item1JSON->key = $item1Key;
-		$item1JSON->version = 0;
-		$item1JSON->relations->{'dc:relation'} = $item2URI;
-		$item2JSON = API::getItemTemplate('book');
-		$item2JSON->key = $item2Key;
-		$item2JSON->version = 0;
-		
-		$response = API::postItems([$item1JSON, $item2JSON]);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		
-		// Make sure it exists on item 1
-		$json = API::getItem($item1JSON->key, $this, 'json')['data'];
-		$this->assertCount(1, $json['relations']);
-		$this->assertEquals($item2URI, $json['relations']['dc:relation']);
-		
-		// And item 2, since related items are bidirectional
-		$json = API::getItem($item2JSON->key, $this, 'json')['data'];
-		$this->assertCount(1, $json['relations']);
-		$this->assertEquals($item1URI, $json['relations']['dc:relation']);
-	}
-	
-	
-	public function test_should_add_a_URL_to_a_relation_with_PATCH() {
-		$relations = [
-			"dc:replaces" => [
-				"http://zotero.org/users/" . self::$config['userID'] . "/items/AAAAAAAA"
-			]
-		];
-		
-		$itemJSON = API::createItem("book", [
-			"relations" => $relations
-		], $this, 'jsonData');
-		
-		$relations["dc:replaces"][] = "http://zotero.org/users/" . self::$config['userID'] . "/items/BBBBBBBB";
-		
-		$patchJSON = [
-			"version" => $itemJSON['version'],
-			"relations" => $relations
-		];
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/{$itemJSON['key']}",
-			json_encode($patchJSON)
-		);
-		$this->assert204($response);
-		
-		// Make sure the array was updated
-		$json = API::getItem($itemJSON['key'], $this, 'json')['data'];
-		$this->assertCount(sizeOf($relations), $json['relations']);
-		$this->assertCount(sizeOf($relations['dc:replaces']), $json['relations']['dc:replaces']);
-		$this->assertContains($relations['dc:replaces'][0], $json['relations']['dc:replaces']);
-		$this->assertContains($relations['dc:replaces'][1], $json['relations']['dc:replaces']);
-	}
-	
-	
-	public function test_should_remove_a_URL_from_a_relation_with_PATCH() {
-		$userID = self::$config['userID'];
-		
-		$relations = [
-			"dc:replaces" => [
-				"http://zotero.org/users/$userID/items/AAAAAAAA",
-				"http://zotero.org/users/$userID/items/BBBBBBBB"
-			]
-		];
-		
-		$itemJSON = API::createItem("book", [
-			"relations" => $relations
-		], $this, 'jsonData');
-		
-		$relations["dc:replaces"] = array_slice($relations["dc:replaces"], 0, 1);
-		
-		$patchJSON = [
-			"version" => $itemJSON['version'],
-			"relations" => $relations
-		];
-		$response = API::userPatch(
-			self::$config['userID'],
-			"items/{$itemJSON['key']}",
-			json_encode($patchJSON)
-		);
-		$this->assert204($response);
-		
-		// Make sure the value (now a string) was updated
-		$json = API::getItem($itemJSON['key'], $this, 'json')['data'];
-		$this->assertEquals($relations['dc:replaces'][0], $json['relations']['dc:replaces']);
-	}
-	
-	
-	public function testInvalidItemRelation() {
-		$response = API::createItem("book", array(
-			"relations" => array(
-				"foo:unknown" => "http://zotero.org/groups/1/items/AAAAAAAA"
-			)
-		), $this, 'response');
-		$this->assert400ForObject($response, "Unsupported predicate 'foo:unknown'");
-		
-		$response = API::createItem("book", array(
-			"relations" => array(
-				"owl:sameAs" => "Not a URI"
-			)
-		), $this, 'response');
-		$this->assert400ForObject($response, "'relations' values currently must be Zotero item URIs");
-		
-		$response = API::createItem("book", array(
-			"relations" => array(
-				"owl:sameAs" => ["Not a URI"]
-			)
-		), $this, 'response');
-		$this->assert400ForObject($response, "'relations' values currently must be Zotero item URIs");
-	}
-	
-	
-	public function testCircularItemRelations() {
-		$item1Data = API::createItem("book", null, $this, 'jsonData');
-		$item2Data = API::createItem("book", null, $this, 'jsonData');
-		$userID = self::$config['userID'];
-		
-		$item1Data['relations'] = [
-			'dc:relation' => "http://zotero.org/users/$userID/items/{$item2Data['key']}"
-		];
-		$item2Data['relations'] = [
-			'dc:relation' => "http://zotero.org/users/$userID/items/{$item1Data['key']}"
-		];
-		$response = API::postItems([$item1Data, $item2Data]);
-		$this->assert200ForObject($response, false, 0);
-		$this->assertUnchangedForObject($response, 1);
-	}
-	
-	
-	public function testDeleteItemRelation() {
-		$relations = array(
-			"owl:sameAs" => [
-				"http://zotero.org/groups/1/items/AAAAAAAA",
-				"http://zotero.org/groups/1/items/BBBBBBBB"
-			],
-			"dc:relation" => "http://zotero.org/users/"
-				. self::$config['userID'] . "/items/AAAAAAAA",
-		);
-		
-		$data = API::createItem("book", array(
-			"relations" => $relations
-		), $this, 'jsonData');
-		$itemKey = $data['key'];
-		
-		// Remove a relation
-		$data['relations']['owl:sameAs'] = $relations['owl:sameAs'] = $relations['owl:sameAs'][0];
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$itemKey",
-			json_encode($data)
-		);
-		$this->assert204($response);
-		
-		// Make sure it's gone
-		$data = API::getItem($data['key'], $this, 'json')['data'];
-		$this->assertCount(sizeOf($relations), $data['relations']);
-		foreach ($relations as $predicate => $object) {
-			$this->assertEquals($object, $data['relations'][$predicate]);
-		}
-		
-		// Delete all
-		$data['relations'] = new \stdClass;
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$itemKey",
-			json_encode($data)
-		);
-		$this->assert204($response);
-		
-		// Make sure they're gone
-		$data = API::getItem($itemKey, $this, 'json')['data'];
-		$this->assertCount(0, $data['relations']);
-	}
-	
-	
-	//
-	// Collections
-	//
-	public function testNewCollectionRelations() {
-		$relations = array(
-			"owl:sameAs" => "http://zotero.org/groups/1/collections/AAAAAAAA"
-		);
-		
-		$data = API::createCollection("Test", array(
-			"relations" => $relations
-		), $this, 'jsonData');
-		$this->assertCount(sizeOf($relations), $data['relations']);
-		foreach ($relations as $predicate => $object) {
-			$this->assertEquals($object, $data['relations'][$predicate]);
-		}
-	}
-	
-	
-	public function testInvalidCollectionRelation() {
-		$json = [
-			"name" => "Test",
-			"relations" => array(
-				"foo:unknown" => "http://zotero.org/groups/1/collections/AAAAAAAA"
-			)
-		];
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections",
-			json_encode([$json])
-		);
-		$this->assert400ForObject($response, "Unsupported predicate 'foo:unknown'");
-		
-		$json["relations"] = array(
-			"owl:sameAs" => "Not a URI"
-		);
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections",
-			json_encode([$json])
-		);
-		$this->assert400ForObject($response, "'relations' values currently must be Zotero collection URIs");
-		
-		$json["relations"] = ["http://zotero.org/groups/1/collections/AAAAAAAA"];
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections",
-			json_encode([$json])
-		);
-		$this->assert400ForObject($response, "'relations' property must be an object");
-	}
-	
-	
-	public function testDeleteCollectionRelation() {
-		$relations = array(
-			"owl:sameAs" => "http://zotero.org/groups/1/collections/AAAAAAAA"
-		);
-		$data = API::createCollection("Test", array(
-			"relations" => $relations
-		), $this, 'jsonData');
-		
-		// Remove all relations
-		$data['relations'] = new \stdClass;
-		unset($relations['owl:sameAs']);
-		$response = API::userPut(
-			self::$config['userID'],
-			"collections/{$data['key']}",
-			json_encode($data)
-		);
-		$this->assert204($response);
-		
-		// Make sure it's gone
-		$data = API::getCollection($data['key'], $this, 'json')['data'];
-		$this->assertCount(sizeOf($relations), $data['relations']);
-		foreach ($relations as $predicate => $object) {
-			$this->assertEquals($object, $data['relations'][$predicate]);
-		}
-	}
-	
-	
-	public function test_should_return_200_for_values_for_mendeleyDB_collection_relation() {
-		$relations = [
-			"mendeleyDB:remoteFolderUUID" => "b95b84b9-8b27-4a55-b5ea-5b98c1cac205"
-		];
-		$data = API::createCollection(
-			"Test",
-			[
-				"relations" => $relations
-			],
-			$this,
-			'jsonData'
-		);
-		$this->assertEquals($relations['mendeleyDB:remoteFolderUUID'], $data['relations']['mendeleyDB:remoteFolderUUID']);
-	}
-	
-	
-	public function test_should_return_200_for_arrays_for_mendeleyDB_collection_relation() {
-		$json = [
-			"name" => "Test",
-			"relations" => [
-				"mendeleyDB:remoteFolderUUID" => ["b95b84b9-8b27-4a55-b5ea-5b98c1cac205"]
-			]
-        ];
-		$response = API::userPost(
-			self::$config['userID'],
-			"collections",
-			json_encode([$json])
-		);
-		$this->assert200ForObject($response);
-	}
-}
diff --git a/tests/remote/tests/API/3/SearchTest.php b/tests/remote/tests/API/3/SearchTest.php
deleted file mode 100644
index dae71383..00000000
--- a/tests/remote/tests/API/3/SearchTest.php
+++ /dev/null
@@ -1,330 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class SearchTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testNewSearch() {
-		$name = "Test Search";
-		$conditions = [
-			[
-				"condition" => "title",
-				"operator" => "contains",
-				"value" => "test"
-			],
-			[
-				"condition" => "noChildren",
-				"operator" => "false",
-				"value" => ""
-			],
-			[
-				"condition" => "fulltextContent/regexp",
-				"operator" => "contains",
-				"value" => "/test/"
-			]
-		];
-		
-		// DEBUG: Should fail with no version?
-		$response = API::userPost(
-			self::$config['userID'],
-			"searches",
-			json_encode([[
-				"name" => $name,
-				"conditions" => $conditions
-			]]),
-			["Content-Type: application/json"]
-		);
-		$this->assert200($response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(1, $json['successful']);
-		// Deprecated
-		$this->assertCount(1, $json['success']);
-		
-		// Check data in write response
-		$data = $json['successful'][0]['data'];
-		$this->assertEquals($json['successful'][0]['key'], $data['key']);
-		$this->assertEquals($libraryVersion, $data['version']);
-		$this->assertEquals($libraryVersion, $data['version']);
-		$this->assertEquals($name, $data['name']);
-		$this->assertInternalType('array', $data['conditions']);
-		$this->assertCount(sizeOf($conditions), $data['conditions']);
-		foreach ($conditions as $i => $condition) {
-			foreach ($condition as $key => $val) {
-				$this->assertEquals($val, $data['conditions'][$i][$key]);
-			}
-		}
-		
-		
-		// Check in separate request, to be safe
-		$keys = array_map(function ($o) {
-			return $o['key'];
-		}, $json['successful']);
-		$response = API::getSearchResponse($keys);
-		$this->assertTotalResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$data = $json[0]['data'];
-		$this->assertEquals($name, $data['name']);
-		$this->assertInternalType('array', $data['conditions']);
-		$this->assertCount(sizeOf($conditions), $data['conditions']);
-		foreach ($conditions as $i => $condition) {
-			foreach ($condition as $key => $val) {
-				$this->assertEquals($val, $data['conditions'][$i][$key]);
-			}
-		}
-		
-		return $data;
-	}
-	
-	
-	/**
-	 * @depends testNewSearch
-	 */
-	public function testModifySearch($data) {
-		$key = $data['key'];
-		$version = $data['version'];
-		
-		// Remove one search condition
-		array_shift($data['conditions']);
-		
-		$name = $data['name'];
-		$conditions = $data['conditions'];
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"searches/$key",
-			json_encode($data),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert204($response);
-		
-		$data = API::getSearch($key, $this, 'json')['data'];
-		$this->assertEquals($name, (string) $data['name']);
-		$this->assertInternalType('array', $data['conditions']);
-		$this->assertCount(sizeOf($conditions), $data['conditions']);
-		foreach ($conditions as $i => $condition) {
-			foreach ($condition as $key => $val) {
-				$this->assertEquals($val, $data['conditions'][$i][$key]);
-			}
-		}
-	}
-	
-	
-	public function testEditMultipleSearches() {
-		$search1Name = "Test 1";
-		$search1Conditions = [
-			[
-				"condition" => "title",
-				"operator" => "contains",
-				"value" => "test"
-			]
-		];
-		$search1Data = API::createSearch($search1Name, $search1Conditions, $this, 'jsonData');
-		$search1NewName = "Test 1 Modified";
-		
-		$search2Name = "Test 2";
-		$search2Conditions = [
-			[
-				"condition" => "title",
-				"operator" => "is",
-				"value" => "test2"
-			]
-		];
-		$search2Data = API::createSearch($search2Name, $search2Conditions, $this, 'jsonData');
-		$search2NewConditions = [
-			[
-				"condition" => "title",
-				"operator" => "isNot",
-				"value" => "test1"
-			]
-		];
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"searches",
-			json_encode([
-				[
-					'key' => $search1Data['key'],
-					'version' => $search1Data['version'],
-					'name' => $search1NewName
-				],
-				[
-					'key' => $search2Data['key'],
-					'version' => $search2Data['version'],
-					'conditions' => $search2NewConditions
-				]
-			]),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert200($response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		$json = API::getJSONFromResponse($response);
-		$this->assertCount(2, $json['successful']);
-		// Deprecated
-		$this->assertCount(2, $json['success']);
-		
-		// Check data in write response
-		$this->assertEquals($json['successful'][0]['key'], $json['successful'][0]['data']['key']);
-		$this->assertEquals($json['successful'][1]['key'], $json['successful'][1]['data']['key']);
-		$this->assertEquals($libraryVersion, $json['successful'][0]['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][1]['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][0]['data']['version']);
-		$this->assertEquals($libraryVersion, $json['successful'][1]['data']['version']);
-		$this->assertEquals($search1NewName, $json['successful'][0]['data']['name']);
-		$this->assertEquals($search2Name, $json['successful'][1]['data']['name']);
-		$this->assertEquals($search1Conditions, $json['successful'][0]['data']['conditions']);
-		$this->assertEquals($search2NewConditions, $json['successful'][1]['data']['conditions']);
-		
-		// Check in separate request, to be safe
-		$keys = array_map(function ($o) {
-			return $o['key'];
-		}, $json['successful']);
-		$response = API::getSearchResponse($keys);
-		$this->assertTotalResults(2, $response);
-		$json = API::getJSONFromResponse($response);
-		// POST follows PATCH behavior, so unspecified values shouldn't change
-		$this->assertEquals($search1NewName, $json[0]['data']['name']);
-		$this->assertEquals($search1Conditions, $json[0]['data']['conditions']);
-		$this->assertEquals($search2Name, $json[1]['data']['name']);
-		$this->assertEquals($search2NewConditions, $json[1]['data']['conditions']);
-	}
-	
-	
-	public function testNewSearchNoName() {
-		$json = API::createSearch(
-			"",
-			array(
-				array(
-					"condition" => "title",
-					"operator" => "contains",
-					"value" => "test"
-				)
-			),
-			$this,
-			'responseJSON'
-		);
-		$this->assert400ForObject($json, "Search name cannot be empty");
-	}
-	
-	
-	public function test_should_allow_a_search_with_emoji_values() {
-		$json = API::createSearch(
-			"🐶", // 4-byte character
-			[
-				[
-					"condition" => "title",
-					"operator" => "contains",
-					"value" => "🐶" // 4-byte character
-				]
-			],
-			$this,
-			'responseJSON'
-		);
-		$this->assert200ForObject($json);
-	}
-	
-	
-	public function testNewSearchNoConditions() {
-		$json = API::createSearch("Test", array(), $this, 'responseJSON');
-		$this->assert400ForObject($json, "'conditions' cannot be empty");
-	}
-	
-	
-	public function testNewSearchConditionErrors() {
-		$json = API::createSearch(
-			"Test",
-			array(
-				array(
-					"operator" => "contains",
-					"value" => "test"
-				)
-			),
-			$this,
-			'responseJSON'
-		);
-		$this->assert400ForObject($json, "'condition' property not provided for search condition");
-		
-		$json = API::createSearch(
-			"Test",
-			array(
-				array(
-					"condition" => "",
-					"operator" => "contains",
-					"value" => "test"
-				)
-			),
-			$this,
-			'responseJSON'
-		);
-		$this->assert400ForObject($json, "Search condition cannot be empty");
-		
-		$json = API::createSearch(
-			"Test",
-			array(
-				array(
-					"condition" => "title",
-					"value" => "test"
-				)
-			),
-			$this,
-			'responseJSON'
-		);
-		$this->assert400ForObject($json, "'operator' property not provided for search condition");
-		
-		$json = API::createSearch(
-			"Test",
-			array(
-				array(
-					"condition" => "title",
-					"operator" => "",
-					"value" => "test"
-				)
-			),
-			$this,
-			'responseJSON'
-		);
-		$this->assert400ForObject($json, "Search operator cannot be empty");
-	}
-}
diff --git a/tests/remote/tests/API/3/SettingsTest.php b/tests/remote/tests/API/3/SettingsTest.php
deleted file mode 100644
index 853e970a..00000000
--- a/tests/remote/tests/API/3/SettingsTest.php
+++ /dev/null
@@ -1,663 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class SettingsTests extends APITests {
-	public function setUp() {
-		parent::setUp();
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-	}
-	
-	
-	public function tearDown() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-	}
-	
-	
-	public function testAddUserSetting() {
-		$settingKey = "tagColors";
-		$value = array(
-			array(
-				"name" => "_READ",
-				"color" => "#990000"
-			)
-		);
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$json = array(
-			"value" => $value
-		);
-		
-		// No version
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert428($response);
-		
-		// Version must be 0 for non-existent setting
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: 1"
-			)
-		);
-		$this->assert412($response);
-		
-		// Create
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: 0"
-			)
-		);
-		$this->assert204($response);
-		
-		// Multi-object GET
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertArrayHasKey($settingKey, $json);
-		$this->assertEquals($value, $json[$settingKey]['value']);
-		$this->assertEquals($libraryVersion + 1, $json[$settingKey]['version']);
-		
-		// Single-object GET
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($value, $json['value']);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-	}
-	
-	
-	public function testAddUserSettingMultiple() {
-		$json = [
-			"tagColors" => [
-				"value" => [
-					[
-						"name" => "_READ",
-						"color" => "#990000"
-					]
-				]
-			],
-			"feeds" => [
-				"value" => [
-					"http://www.nytimes.com/services/xml/rss/nyt/HomePage.xml" => [
-						"url" => "http://www.nytimes.com/services/xml/rss/nyt/HomePage.xml",
-						"name" => "NYT > Home Page",
-						"cleanupAfter" => 2,
-						"refreshInterval" => 60
-					]
-				]
-			]
-		];
-		$settingKeys = array_keys($json);
-		$json = json_decode(json_encode($json));
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"settings",
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		$this->assertEquals(++$libraryVersion, $response->getHeader("Last-Modified-Version"));
-		
-		// Multi-object GET
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json2 = json_decode($response->getBody());
-		$this->assertNotNull($json2);
-		foreach ($settingKeys as $settingKey) {
-			$this->assertObjectHasAttribute($settingKey, $json2, "Object should have $settingKey property");
-			$this->assertEquals($json->$settingKey->value, $json2->$settingKey->value, "'$settingKey' value should match");
-			$this->assertEquals($libraryVersion, $json2->$settingKey->version, "'$settingKey' version should match");
-		}
-		
-		// Single-object GET
-		foreach ($settingKeys as $settingKey) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"settings/$settingKey"
-			);
-			$this->assert200($response);
-			$this->assertContentType("application/json", $response);
-			$json2 = json_decode($response->getBody());
-			$this->assertNotNull($json2);
-			$this->assertEquals($json->$settingKey->value, $json2->value);
-			$this->assertEquals($libraryVersion, $json2->version);
-		}
-	}
-	
-	
-	public function testAddGroupSettingMultiple() {
-		$settingKey = "tagColors";
-		$value = array(
-			array(
-				"name" => "_READ",
-				"color" => "#990000"
-			)
-		);
-		
-		// TODO: multiple, once more settings are supported
-		
-		$groupID = self::$config['ownedPrivateGroupID'];
-		$libraryVersion = API::getGroupLibraryVersion($groupID);
-		
-		$json = array(
-			$settingKey => array(
-				"value" => $value
-			)
-		);
-		$response = API::groupPost(
-			$groupID,
-			"settings",
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		
-		// Multi-object GET
-		$response = API::groupGet(
-			$groupID,
-			"settings"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertArrayHasKey($settingKey, $json);
-		$this->assertEquals($value, $json[$settingKey]['value']);
-		$this->assertEquals($libraryVersion + 1, $json[$settingKey]['version']);
-		
-		// Single-object GET
-		$response = API::groupGet(
-			$groupID,
-			"settings/$settingKey"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($value, $json['value']);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-	}
-	
-	
-	public function testUpdateUserSetting() {
-		$settingKey = "tagColors";
-		$value = array(
-			array(
-				"name" => "_READ",
-				"color" => "#990000"
-			)
-		);
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$json = array(
-			"value" => $value,
-			"version" => 0
-		);
-		
-		// Create
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			array(
-				"Content-Type: application/json"
-			)
-		);
-		$this->assert204($response);
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($value, $json['value']);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-		
-		// Update with no change
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			array(
-				"Content-Type: application/json"
-			)
-		);
-		$this->assert204($response);
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($value, $json['value']);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-		
-		$newValue = array(
-			array(
-				"name" => "_READ",
-				"color" => "#CC9933"
-			)
-		);
-		$json['value'] = $newValue;
-		
-		// Update, no change
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			array(
-				"Content-Type: application/json"
-			)
-		);
-		$this->assert204($response);
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($newValue, $json['value']);
-		$this->assertEquals($libraryVersion + 2, $json['version']);
-	}
-	
-	
-	public function testUpdateUserSettings() {
-		$settingKey = "tagColors";
-		$value = [
-			[
-				"name" => "_READ",
-				"color" => "#990000"
-			]
-		];
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$json = [
-			"value" => $value,
-			"version" => 0
-		];
-		
-		// Create
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert204($response);
-		$this->assertEquals(++$libraryVersion, $response->getHeader('Last-Modified-Version'));
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$this->assertEquals($libraryVersion, $response->getHeader('Last-Modified-Version'));
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertArrayHasKey($settingKey, $json);
-		$this->assertEquals($value, $json[$settingKey]['value']);
-		$this->assertEquals($libraryVersion, $json[$settingKey]['version']);
-		
-		// Update with no change
-		$response = API::userPost(
-			self::$config['userID'],
-			"settings",
-			json_encode([
-				$settingKey => [
-					"value" => $value
-				]
-			]),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $libraryVersion"
-			]
-		);
-		$this->assert204($response);
-		$this->assertEquals($libraryVersion, $response->getHeader('Last-Modified-Version'));
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$this->assertEquals($libraryVersion, $response->getHeader('Last-Modified-Version'));
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertArrayHasKey($settingKey, $json);
-		$this->assertEquals($value, $json[$settingKey]['value']);
-		$this->assertEquals($libraryVersion, $json[$settingKey]['version']);
-		
-		$newValue = [
-			[
-				"name" => "_READ",
-				"color" => "#CC9933"
-			]
-		];
-		
-		// Update
-		$response = API::userPost(
-			self::$config['userID'],
-			"settings",
-			json_encode([
-				$settingKey => [
-					"value" => $newValue
-				]
-			]),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $libraryVersion"
-			]
-		);
-		$this->assert204($response);
-		$this->assertEquals(++$libraryVersion, $response->getHeader('Last-Modified-Version'));
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings"
-		);
-		$this->assert200($response);
-		$this->assertContentType("application/json", $response);
-		$this->assertEquals($libraryVersion, $response->getHeader('Last-Modified-Version'));
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertArrayHasKey($settingKey, $json);
-		$this->assertEquals($newValue, $json[$settingKey]['value']);
-		$this->assertEquals($libraryVersion, $json[$settingKey]['version']);
-	}
-	
-	
-	public function testDeleteUserSetting() {
-		$settingKey = "tagColors";
-		$value = array(
-			array(
-				"name" => "_READ",
-				"color" => "#990000"
-			)
-		);
-		
-		$json = array(
-			"value" => $value,
-			"version" => 0
-		);
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Create
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert204($response);
-		
-		// Delete
-		$response = API::userDelete(
-			self::$config['userID'],
-			"settings/$settingKey",
-			array(
-				"If-Unmodified-Since-Version: " . ($libraryVersion + 1)
-			)
-		);
-		$this->assert204($response);
-		
-		// Check
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey"
-		);
-		$this->assert404($response);
-		
-		$this->assertEquals($libraryVersion + 2, API::getLibraryVersion());
-	}
-	
-	
-	public function testDeleteNonexistentSetting() {
-		$response = API::userDelete(
-			self::$config['userID'],
-			"settings/nonexistentSetting",
-			array(
-				"If-Unmodified-Since-Version: 0"
-			)
-		);
-		$this->assert404($response);
-	}
-	
-	
-	public function testSettingsSince() {
-		$libraryVersion1 = API::getLibraryVersion();
-		$response = API::userPost(
-			self::$config['userID'],
-			"settings",
-			json_encode([
-				"tagColors" => [
-					"value" => [
-						[
-							"name" => "_READ",
-							"color" => "#990000"
-						]
-					]
-				]
-			])
-		);
-		$this->assert204($response);
-		$libraryVersion2 = $response->getHeader("Last-Modified-Version");
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"settings",
-			json_encode([
-				"feeds" => [
-					"value" => [
-						"http://www.nytimes.com/services/xml/rss/nyt/HomePage.xml" => [
-							"url" => "http://www.nytimes.com/services/xml/rss/nyt/HomePage.xml",
-							"name" => "NYT > Home Page",
-							"cleanupAfter" => 2,
-							"refreshInterval" => 60
-						]
-					]
-				]
-			])
-		);
-		$this->assert204($response);
-		$libraryVersion3 = $response->getHeader("Last-Modified-Version");
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings?since=$libraryVersion1"
-		);
-		$this->assertNumResults(2, $response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings?since=$libraryVersion2"
-		);
-		$this->assertNumResults(1, $response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings?since=$libraryVersion3"
-		);
-		$this->assertNumResults(0, $response);
-	}
-	
-	
-	public function testUnsupportedSetting() {
-		$settingKey = "unsupportedSetting";
-		$value = true;
-		
-		$json = array(
-			"value" => $value,
-			"version" => 0
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert400($response, "Invalid setting '$settingKey'");
-	}
-	
-	
-	public function testUnsupportedSettingMultiple() {
-		$settingKey = "unsupportedSetting";
-		$json = array(
-			"tagColors" => array(
-				"value" => array(
-					"name" => "_READ",
-					"color" => "#990000"
-				),
-				"version" => 0
-			),
-			$settingKey => array(
-				"value" => false,
-				"version" => 0
-			)
-		);
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert400($response, "Invalid setting '$settingKey'");
-		
-		// Valid setting shouldn't exist, and library version should be unchanged
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey"
-		);
-		$this->assert404($response);
-		$this->assertEquals($libraryVersion, API::getLibraryVersion());
-	}
-	
-	
-	public function testOverlongSetting() {
-		$settingKey = "tagColors";
-		$value = array(
-			array(
-				"name" => $this->content = str_repeat("abcdefghij", 2001),
-				"color" => "#990000"
-			)
-		);
-		
-		$json = array(
-			"value" => $value,
-			"version" => 0
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			array("Content-Type: application/json")
-		);
-		$this->assert400($response, "'value' cannot be longer than 20000 characters");
-	}
-	
-	
-	public function test_should_allow_emoji_character() {
-		$settingKey = "tagColors";
-		$value = [
-			[
-				"name" => "🐶",
-				"color" => "#990000"
-			]
-		];
-		$json = [
-			"value" => $value,
-			"version" => 0
-		];
-		$response = API::userPut(
-			self::$config['userID'],
-			"settings/$settingKey",
-			json_encode($json),
-			["Content-Type: application/json"]
-		);
-		$this->assert204($response);
-	}
-}
diff --git a/tests/remote/tests/API/3/SortTest.php b/tests/remote/tests/API/3/SortTest.php
deleted file mode 100644
index 08be64e3..00000000
--- a/tests/remote/tests/API/3/SortTest.php
+++ /dev/null
@@ -1,517 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class SortTests extends APITests {
-	private static $collectionKeys = [];
-	private static $itemKeys = [];
-	private static $childAttachmentKeys = [];
-	private static $childNoteKeys = [];
-	private static $searchKeys = [];
-	
-	private static $titles = ['q', 'c', 'a', 'j', 'e', 'h', 'i'];
-	private static $names = ['m', 's', 'a', 'bb', 'ba', '', ''];
-	private static $attachmentTitles = ['v', 'x', null, 'a', null];
-	private static $notes = [null, 'aaa', null, null, 'taf'];
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-		
-		//
-		// Collections
-		//
-		/*for ($i=0; $i<5; $i++) {
-			self::$collectionKeys[] = API::createCollection("Test", false, null, 'key');
-		}*/
-		
-		//
-		// Items
-		//
-		$titles = self::$titles;
-		$names = self::$names;
-		for ($i = 0; $i < sizeOf(self::$titles) - 2; $i++) {
-			$key = API::createItem("book", [
-				'title' => array_shift($titles),
-				'creators' => [
-					[
-						"creatorType" => "author",
-						"name" => array_shift($names)
-					]
-				]
-			], null, 'key');
-			
-			// Child attachments
-			if (!is_null(self::$attachmentTitles[$i])) {
-				self::$childAttachmentKeys[] = API::createAttachmentItem("imported_file", [
-					'title' => self::$attachmentTitles[$i]
-				], $key, null, 'key');
-			}
-			// Child notes
-			if (!is_null(self::$notes[$i])) {
-				self::$childNoteKeys[] = API::createNoteItem(self::$notes[$i], $key, null, 'key');
-			}
-			
-			self::$itemKeys[] = $key;
-		}
-		// Top-level attachment
-		self::$itemKeys[] = API::createAttachmentItem("imported_file", [
-			'title' => array_shift($titles)
-		], false, null, 'key');
-		// Top-level note
-		self::$itemKeys[] = API::createNoteItem(array_shift($titles), false, null, 'key');
-		
-		//
-		// Searches
-		//
-		/*for ($i=0; $i<5; $i++) {
-			self::$searchKeys[] = API::createSearch("Test", 'default', null, 'key');
-		}*/
-	}
-	
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testSortTopItemsTitle() {
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?format=keys&sort=title"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$titles = self::$titles;
-		asort($titles);
-		$this->assertCount(sizeOf($titles), $keys);
-		$correct = [];
-		foreach ($titles as $k => $v) {
-			// The key at position k in itemKeys should be at the same position in keys
-			$correct[] = self::$itemKeys[$k];
-		}
-		$this->assertEquals($correct, $keys);
-	}
-	
-	
-	// Same thing, but with order parameter for backwards compatibility
-	public function testSortTopItemsTitleOrder() {
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?format=keys&order=title"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$titles = self::$titles;
-		asort($titles);
-		$this->assertCount(sizeOf($titles), $keys);
-		$correct = [];
-		foreach ($titles as $k => $v) {
-			// The key at position k in itemKeys should be at the same position in keys
-			$correct[] = self::$itemKeys[$k];
-		}
-		$this->assertEquals($correct, $keys);
-	}
-	
-	
-	public function testSortTopItemsCreator() {
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?format=keys&sort=creator"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$names = self::$names;
-		uasort($names, function ($a, $b) {
-			if ($a === '' && $b !== '') return 1;
-			if ($b === '' && $a !== '') return -1;
-			return strcmp($a, $b);
-		});
-		$this->assertCount(sizeOf($names), $keys);
-		$endKeys = array_splice($keys, -2);
-		$correct = [];
-		foreach ($names as $k => $v) {
-			// The key at position k in itemKeys should be at the same position in keys
-			$correct[] = self::$itemKeys[$k];
-		}
-		// Remove empty names
-		array_splice($correct, -2);
-		$this->assertEquals($correct, $keys);
-		// Check attachment and note, which should fall back to ordered added (itemID)
-		$this->assertEquals(array_slice(self::$itemKeys, -2), $endKeys);
-	}
-	
-	
-	// Same thing, but with 'order' for backwards compatibility
-	public function testSortTopItemsCreatorOrder() {
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?format=keys&order=creator"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$names = self::$names;
-		uasort($names, function ($a, $b) {
-			if ($a === '' && $b !== '') return 1;
-			if ($b === '' && $a !== '') return -1;
-			return strcmp($a, $b);
-		});
-		$this->assertCount(sizeOf($names), $keys);
-		$endKeys = array_splice($keys, -2);
-		$correct = [];
-		foreach ($names as $k => $v) {
-			// The key at position k in itemKeys should be at the same position in keys
-			$correct[] = self::$itemKeys[$k];
-		}
-		// Remove empty names
-		array_splice($correct, -2);
-		$this->assertEquals($correct, $keys);
-		// Check attachment and note, which should fall back to ordered added (itemID)
-		$this->assertEquals(array_slice(self::$itemKeys, -2), $endKeys);
-	}
-	
-	
-	// Old sort=asc, with no 'order' param
-	public function testSortSortParamAsDirectionWithoutOrder() {
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&sort=asc"
-		);
-		// We can't test dateAdded without adding lots of delays,
-		// so just make sure this doesn't throw an error
-		$this->assert200($response);
-	}
-	
-	
-	//
-	//
-	// Test below here do their own clears
-	//
-	//
-	
-	
-	public function testSortDefault() {
-		API::userClear(self::$config['userID']);
-		
-		// Setup
-		$dataArray = [];
-		
-		$dataArray[] = API::createItem("book", [
-			'title' => "B",
-			'creators' => [
-				[
-					"creatorType" => "author",
-					"name" => "B"
-				]
-			],
-			'dateAdded' => '2014-02-05T00:00:00Z',
-			'dateModified' => '2014-04-05T01:00:00Z'
-		], $this, 'jsonData');
-		
-		$dataArray[] = API::createItem("journalArticle", [
-			'title' => "A",
-			'creators' => [
-				[
-					"creatorType" => "author",
-					"name" => "A"
-				]
-			],
-			'dateAdded' => '2014-02-04T00:00:00Z',
-			'dateModified' => '2014-01-04T01:00:00Z'
-		], $this, 'jsonData');
-		
-		$dataArray[] = API::createItem("newspaperArticle", [
-			'title' => "F",
-			'creators' => [
-				[
-					"creatorType" => "author",
-					"name" => "F"
-				]
-			],
-			'dateAdded' => '2014-02-03T00:00:00Z',
-			'dateModified' => '2014-02-03T01:00:00Z'
-
-		], $this, 'jsonData');
-		
-		
-		$dataArray[] = API::createItem("book", [
-			'title' => "C",
-			'creators' => [
-				[
-					"creatorType" => "author",
-					"name" => "C"
-				]
-			],
-			'dateAdded' => '2014-02-02T00:00:00Z',
-			'dateModified' => '2014-03-02T01:00:00Z'
-		], $this, 'jsonData');
-		
-		// Get sorted keys
-		usort($dataArray, function ($a, $b) {
-			return strcmp($a['dateAdded'], $b['dateAdded']) * -1;
-		});
-		$keysByDateAddedDescending = array_map(function ($data) {
-			return $data['key'];
-		}, $dataArray);
-		usort($dataArray, function ($a, $b) {
-			return strcmp($a['dateModified'], $b['dateModified']) * -1;
-		});
-		$keysByDateModifiedDescending = array_map(function ($data) {
-			return $data['key'];
-		}, $dataArray);
-		
-		// Tests
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys"
-		);
-		$this->assert200($response);
-		$this->assertEquals($keysByDateModifiedDescending, explode("\n", trim($response->getBody())));
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=json"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$keys = array_map(function ($val) {
-			return $val['key'];
-		}, $json);
-		$this->assertEquals($keysByDateModifiedDescending, $keys);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=atom"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$keys = array_map(function ($val) {
-			return (string) $val;
-		}, $xml->xpath('//atom:entry/zapi:key'));
-		$this->assertEquals($keysByDateAddedDescending, $keys);
-	}
-	
-	
-	public function testSortDirection() {
-		API::userClear(self::$config['userID']);
-		
-		// Setup
-		$dataArray = [];
-		
-		$dataArray[] = API::createItem("book", [
-			'title' => "B",
-			'creators' => [
-				[
-					"creatorType" => "author",
-					"name" => "B"
-				]
-			],
-			'dateAdded' => '2014-02-05T00:00:00Z',
-			'dateModified' => '2014-04-05T01:00:00Z'
-		], $this, 'jsonData');
-		
-		$dataArray[] = API::createItem("journalArticle", [
-			'title' => "A",
-			'creators' => [
-				[
-					"creatorType" => "author",
-					"name" => "A"
-				]
-			],
-			'dateAdded' => '2014-02-04T00:00:00Z',
-			'dateModified' => '2014-01-04T01:00:00Z'
-		], $this, 'jsonData');
-		
-		$dataArray[] = API::createItem("newspaperArticle", [
-			'title' => "F",
-			'creators' => [
-				[
-					"creatorType" => "author",
-					"name" => "F"
-				]
-			],
-			'dateAdded' => '2014-02-03T00:00:00Z',
-			'dateModified' => '2014-02-03T01:00:00Z'
-
-		], $this, 'jsonData');
-		
-		
-		$dataArray[] = API::createItem("book", [
-			'title' => "C",
-			'creators' => [
-				[
-					"creatorType" => "author",
-					"name" => "C"
-				]
-			],
-			'dateAdded' => '2014-02-02T00:00:00Z',
-			'dateModified' => '2014-03-02T01:00:00Z'
-		], $this, 'jsonData');
-		
-		// Get sorted keys
-		usort($dataArray, function ($a, $b) {
-			return strcmp($a['dateAdded'], $b['dateAdded']);
-		});
-		$keysByDateAddedAscending = array_map(function ($data) {
-			return $data['key'];
-		}, $dataArray);
-		
-		$keysByDateAddedDescending = array_reverse($keysByDateAddedAscending);
-		
-		// Ascending
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&sort=dateAdded&direction=asc"
-		);
-		$this->assert200($response);
-		$this->assertEquals($keysByDateAddedAscending, explode("\n", trim($response->getBody())));
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=json&sort=dateAdded&direction=asc"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$keys = array_map(function ($val) {
-			return $val['key'];
-		}, $json);
-		$this->assertEquals($keysByDateAddedAscending, $keys);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=atom&sort=dateAdded&direction=asc"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$keys = array_map(function ($val) {
-			return (string) $val;
-		}, $xml->xpath('//atom:entry/zapi:key'));
-		$this->assertEquals($keysByDateAddedAscending, $keys);
-		
-		// Ascending using old 'order'/'sort' instead of 'sort'/'direction'
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&order=dateAdded&sort=asc"
-		);
-		$this->assert200($response);
-		$this->assertEquals($keysByDateAddedAscending, explode("\n", trim($response->getBody())));
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=json&order=dateAdded&sort=asc"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$keys = array_map(function ($val) {
-			return $val['key'];
-		}, $json);
-		$this->assertEquals($keysByDateAddedAscending, $keys);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=atom&order=dateAdded&sort=asc"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$keys = array_map(function ($val) {
-			return (string) $val;
-		}, $xml->xpath('//atom:entry/zapi:key'));
-		$this->assertEquals($keysByDateAddedAscending, $keys);
-		
-		// Deprecated 'order'/'sort', but the wrong way
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&sort=dateAdded&order=asc"
-		);
-		$this->assert200($response);
-		$this->assertEquals($keysByDateAddedAscending, explode("\n", trim($response->getBody())));
-		
-		// Descending
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&sort=dateAdded&direction=desc"
-		);
-		$this->assert200($response);
-		$this->assertEquals($keysByDateAddedDescending, explode("\n", trim($response->getBody())));
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=json&sort=dateAdded&direction=desc"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$keys = array_map(function ($val) {
-			return $val['key'];
-		}, $json);
-		$this->assertEquals($keysByDateAddedDescending, $keys);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=atom&sort=dateAdded&direction=desc"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$keys = array_map(function ($val) {
-			return (string) $val;
-		}, $xml->xpath('//atom:entry/zapi:key'));
-		$this->assertEquals($keysByDateAddedDescending, $keys);
-		
-		// Descending
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&order=dateAdded&sort=desc"
-		);
-		$this->assert200($response);
-		$this->assertEquals($keysByDateAddedDescending, explode("\n", trim($response->getBody())));
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=json&order=dateAdded&sort=desc"
-		);
-		$this->assert200($response);
-		$json = API::getJSONFromResponse($response);
-		$keys = array_map(function ($val) {
-			return $val['key'];
-		}, $json);
-		$this->assertEquals($keysByDateAddedDescending, $keys);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=atom&order=dateAdded&sort=desc"
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$keys = array_map(function ($val) {
-			return (string) $val;
-		}, $xml->xpath('//atom:entry/zapi:key'));
-		$this->assertEquals($keysByDateAddedDescending, $keys);
-	}
-}
diff --git a/tests/remote/tests/API/3/StorageAdminTest.php b/tests/remote/tests/API/3/StorageAdminTest.php
deleted file mode 100644
index e7b3ad05..00000000
--- a/tests/remote/tests/API/3/StorageAdminTest.php
+++ /dev/null
@@ -1,113 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/bootstrap.inc.php';
-
-class StorageAdminTests extends APITests {
-	const DEFAULT_QUOTA = 300;
-	
-	private static $toDelete = array();
-	
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-	}
-	
-	public function setUp() {
-		parent::setUp();
-		
-		// Clear subscription
-		$response = API::post(
-			'users/' . self::$config['userID'] . '/storageadmin',
-			'quota=0&expiration=0',
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals(self::DEFAULT_QUOTA, (int) $xml->quota);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		
-		// Clear subscription
-		$response = API::post(
-			'users/' . self::$config['userID'] . '/storageadmin',
-			'quota=0&expiration=0',
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-	}
-	
-	
-	public function test2GB() {
-		$quota = 2000;
-		$expiration = time() + (86400 * 365);
-		
-		$response = API::post(
-			'users/' . self::$config['userID'] . '/storageadmin',
-			"quota=$quota&expiration=$expiration",
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals($quota, (int) $xml->quota);
-		$this->assertEquals($expiration, (int) $xml->expiration);
-	}
-	
-	
-	public function testUnlimited() {
-		$quota = 'unlimited';
-		$expiration = time() + (86400 * 365);
-		
-		$response = API::post(
-			'users/' . self::$config['userID'] . '/storageadmin',
-			"quota=$quota&expiration=$expiration",
-			[],
-			[
-				"username" => self::$config['rootUsername'],
-				"password" => self::$config['rootPassword']
-			]
-		);
-		$this->assert200($response);
-		$xml = API::getXMLFromResponse($response);
-		$this->assertEquals($quota, (string) $xml->quota);
-		$this->assertEquals($expiration, (int) $xml->expiration);
-	}
-}
diff --git a/tests/remote/tests/API/3/TagTest.php b/tests/remote/tests/API/3/TagTest.php
deleted file mode 100644
index e5a2961a..00000000
--- a/tests/remote/tests/API/3/TagTest.php
+++ /dev/null
@@ -1,653 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class TagTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		require 'include/config.inc.php';
-		API::userClear($config['userID']);
-	}
-	
-	
-	
-	public function setUp() {
-		parent::setUp();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public function test_empty_tag_should_be_ignored() {
-		$json = API::getItemTemplate("book");
-		$json->tags[] = [
-			"tag" => "A"
-		];
-		$json->tags[] = [
-			"tag" => "",
-			"type" => 1
-		];
-		$response = API::postItem($json);
-		$this->assert200ForObject($response);
-		$json = API::getJSONFromResponse($response);
-		$json = $json['successful'][0]['data'];
-		$this->assertSame($json['tags'], [['tag' => 'A']]);
-	}
-	
-	public function test_empty_tag_with_whitespace_should_be_ignored() {
-		$json = API::getItemTemplate("book");
-		$json->tags[] = [
-			"tag" => "A"
-		];
-		$json->tags[] = [
-			"tag" => " ",
-			"type" => 1
-		];
-		$response = API::postItem($json);
-		$this->assert200ForObject($response);
-		$json = API::getJSONFromResponse($response);
-		$json = $json['successful'][0]['data'];
-		$this->assertSame($json['tags'], [['tag' => 'A']]);
-	}
-	
-	public function testInvalidTagObject() {
-		$json = API::getItemTemplate("book");
-		$json->tags[] = array("invalid");
-		
-		$response = API::postItem($json);
-		$this->assert400ForObject($response, "Tag must be an object");
-	}
-	
-	
-	public function test_should_add_tag_to_item() {
-		$json = API::getItemTemplate("book");
-		$json->tags[] = [
-			"tag" => "A"
-		];
-		$response = API::postItem($json);
-		$this->assert200ForObject($response);
-		$json = API::getJSONFromResponse($response);
-		
-		$json = $json['successful'][0]['data'];
-		$json['tags'][] = [
-			"tag" => "C"
-		];
-		$response = API::postItem($json);
-		$this->assert200ForObject($response);
-		$json = API::getJSONFromResponse($response);
-		
-		$json = $json['successful'][0]['data'];
-		$json['tags'][] = [
-			"tag" => "B"
-		];
-		$response = API::postItem($json);
-		$this->assert200ForObject($response);
-		$json = API::getJSONFromResponse($response);
-		
-		$json = $json['successful'][0]['data'];
-		$json['tags'][] = [
-			"tag" => "D"
-		];
-		$response = API::postItem($json);
-		$this->assert200ForObject($response);
-		$tags = $json['tags'];
-		$json = API::getJSONFromResponse($response);
-		
-		$json = $json['successful'][0]['data'];
-		$this->assertSame($tags, $json['tags']);
-	}
-	
-	
-	public function test_utf8mb4_tag() {
-		$json = API::getItemTemplate("book");
-		$json->tags[] = [
-			"tag" => "🐻", // 4-byte character
-			"type" => 0
-		];
-		
-		$response = API::postItem($json);
-		$this->assert200ForObject($response);
-		
-		$newJSON = API::getJSONFromResponse($response);
-		$newJSON = $newJSON['successful'][0]['data'];
-		$this->assertCount(1, $newJSON['tags']);
-		$this->assertEquals($json->tags[0]['tag'], $newJSON['tags'][0]['tag']);
-	}
-	
-	
-	public function testTagTooLong() {
-		$tag = \Zotero_Utilities::randomString(300);
-		$json = API::getItemTemplate("book");
-		$json->tags[] = [
-			"tag" => $tag,
-			"type" => 1
-		];
-		
-		$response = API::postItem($json);
-		$this->assert413ForObject($response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($tag, $json['failed'][0]['data']['tag']);
-	}
-	
-	
-	public function testItemTagSearch() {
-		API::userClear(self::$config['userID']);
-		
-		// Create items with tags
-		$key1 = API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "b")
-			)
-		), $this, 'key');
-		
-		$key2 = API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "c")
-			)
-		), $this, 'key');
-		
-		//
-		// Searches
-		//
-		
-		// a (both)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&tag=a"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// a and c (#2)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&tag=a&tag=c"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// b and c (none)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&tag=b&tag=c"
-		);
-		$this->assert200($response);
-		$this->assertEmpty(trim($response->getBody()));
-		
-		// b or c (both)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&tag=b%20||%20c"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// a or b or c (both)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&tag=a%20||%20b%20||%20c"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// not a (none)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&tag=-a"
-		);
-		$this->assert200($response);
-		$this->assertEmpty(trim($response->getBody()));
-		
-		// not b (#2)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&tag=-b"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// (b or c) and a (both)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&tag=b%20||%20c&tag=a"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// not nonexistent (both)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&tag=-z"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(2, $keys);
-		$this->assertContains($key1, $keys);
-		$this->assertContains($key2, $keys);
-		
-		// A (case-insensitive search)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&tag=B"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(1, $keys);
-		$this->assertContains($key1, $keys);
-	}
-	
-	
-	public function test_should_handle_negation_in_top_requests() {
-		API::userClear(self::$config['userID']);
-		
-		// Create items with tags
-		$key1 = API::createItem("book", array(
-			"tags" => [
-				["tag" => "a"],
-				["tag" => "b"]
-			]
-		), $this, 'key');
-		
-		$key2 = API::createItem("book", array(
-			"tags" => [
-				["tag" => "a"],
-				["tag" => "c"]
-			]
-		), $this, 'key');
-		API::createAttachmentItem("imported_url", [], $key1, $this, 'jsonData');
-		API::createAttachmentItem("imported_url", [], $key2, $this, 'jsonData');
-		
-		// not b in /top (#2)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/top?format=keys&tag=-b"
-		);
-		$this->assert200($response);
-		$keys = explode("\n", trim($response->getBody()));
-		$this->assertCount(1, $keys);
-		$this->assertContains($key2, $keys);
-	}
-	
-	
-	public function testKeyedItemWithTags() {
-		API::userClear(self::$config['userID']);
-		
-		// Create items with tags
-		require_once '../../model/ID.inc.php';
-		$itemKey = \Zotero_ID::getKey();
-		$json = API::createItem("book", [
-			"key" => $itemKey,
-			"version" => 0,
-			"tags" => [
-				["tag" => "a"],
-				["tag" => "b"]
-			]
-		], $this, 'responseJSON');
-		
-		$json = API::getItem($itemKey, $this, 'json')['data'];
-		$this->assertCount(2, $json['tags']);
-		$this->assertContains(['tag' => 'a'], $json['tags']);
-		$this->assertContains(['tag' => 'b'], $json['tags']);
-	}
-	
-	
-	public function testTagSearch() {
-		$tags1 = array("a", "aa", "b");
-		$tags2 = array("b", "c", "cc");
-		
-		$itemKey1 = API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags1)
-		), $this, 'key');
-		
-		$itemKey2 = API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags2)
-		), $this, 'key');
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?tag=" . implode("%20||%20", $tags1)
-		);
-		$this->assert200($response);
-		$this->assertNumResults(sizeOf($tags1), $response);
-	}
-	
-	
-	public function testOrphanedTag() {
-		$json = API::createItem("book", array(
-			"tags" => [["tag" => "a"]]
-		), $this, 'jsonData');
-		$libraryVersion1 = $json['version'];
-		$itemKey1 = $json['key'];
-		
-		$json = API::createItem("book", array(
-			"tags" => [["tag" => "b"]]
-		), $this, 'jsonData');
-		$itemKey2 = $json['key'];
-		
-		$json = API::createItem("book", array(
-			"tags" => [["tag" => "b"]]
-		), $this, 'jsonData');
-		$itemKey3 = $json['key'];
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"items/$itemKey1",
-			array("If-Unmodified-Since-Version: $libraryVersion1")
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response)[0];
-		$this->assertEquals("b", $json['tag']);
-	}
-	
-	
-	public function testTagNewer() {
-		API::userClear(self::$config['userID']);
-		
-		// Create items with tags
-		API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "b")
-			)
-		), $this);
-		
-		$version = API::getLibraryVersion();
-		
-		// 'newer' shouldn't return any results
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?newer=$version"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(0, $response);
-		
-		// Create another item with tags
-		API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "c")
-			)
-		), $this);
-		
-		// 'newer' should return new tag (Atom)
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?content=json&newer=$version"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$this->assertGreaterThan($version, $response->getHeader('Last-Modified-Version'));
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$data = json_decode($data['content'], true);
-		$this->assertEquals("c", $data['tag']);
-		$this->assertEquals(0, $data['type']);
-		
-		// 'newer' should return new tag (JSON)
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?newer=$version"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$this->assertGreaterThan($version, $response->getHeader('Last-Modified-Version'));
-		$json = API::getJSONFromResponse($response)[0];
-		$this->assertEquals("c", $json['tag']);
-		$this->assertEquals(0, $json['meta']['type']);
-	}
-	
-	
-	public function testMultiTagDelete() {
-		$tags1 = array("a", "aa", "b");
-		$tags2 = array("b", "c", "cc");
-		$tags3 = array("Foo");
-		
-		API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags1)
-		), $this, 'key');
-		
-		API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag, "type" => 1);
-			}, $tags2)
-		), $this, 'key');
-		
-		API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags3)
-		), $this, 'key');
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Missing version header
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?tag=" . implode("%20||%20", array_merge($tags1, $tags2))
-		);
-		$this->assert428($response);
-		
-		// Outdated version header
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?tag=" . implode("%20||%20", array_merge($tags1, $tags2)),
-			array("If-Unmodified-Since-Version: " . ($libraryVersion - 1))
-		);
-		$this->assert412($response);
-		
-		// Delete
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?tag=" . implode("%20||%20", array_merge($tags1, $tags2)),
-			array("If-Unmodified-Since-Version: $libraryVersion")
-		);
-		$this->assert204($response);
-		
-		// Make sure they're gone
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?tag=" . implode("%20||%20", array_merge($tags1, $tags2, $tags3))
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-	}
-	
-	
-	public function test_deleting_a_tag_should_update_a_linked_item() {
-		$tags = ["a", "aa", "b"];
-		
-		$itemKey = API::createItem("book", [
-			"tags" => array_map(function ($tag) {
-				return ["tag" => $tag];
-			}, $tags)
-		], $this, 'key');
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Make sure they're on the item
-		$json = API::getItem($itemKey, $this, 'json');
-		$this->assertEquals($tags, array_map(function ($tag) { return $tag['tag']; }, $json['data']['tags']));
-		
-		// Delete
-		$response = API::userDelete(
-			self::$config['userID'],
-			"tags?tag={$tags[0]}",
-			["If-Unmodified-Since-Version: $libraryVersion"]
-		);
-		$this->assert204($response);
-		
-		// Make sure they're gone from the item
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?since=$libraryVersion"
-		);
-		$this->assert200($response);
-		$this->assertNumResults(1, $response);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals(
-			array_map(function ($tag) { return $tag['tag']; }, $json[0]['data']['tags']),
-			array_slice($tags, 1)
-		);
-	}
-	
-	
-	/**
-	 * When modifying a tag on an item, only the item itself should have its
-	 * version updated, not other items that had (and still have) the same tag
-	 */
-	public function testTagAddItemVersionChange() {
-		$data1 = API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "b")
-			)
-		), $this, 'jsonData');
-		$version1 = $data1['version'];
-		
-		$data2 = API::createItem("book", array(
-			"tags" => array(
-				array("tag" => "a"),
-				array("tag" => "c")
-			)
-		), $this, 'jsonData');
-		$version2 = $data2['version'];
-		
-		// Remove tag 'a' from item 1
-		$json1['tags'] = array(
-			array("tag" => "d"),
-			array("tag" => "c")
-		);
-		
-		$response = API::postItem($data1);
-		$this->assert200($response);
-		
-		// Item 1 version should be one greater than last update
-		$json1 = API::getItem($data1['key'], $this, 'json');
-		$this->assertEquals($version2 + 1, $json1['version']);
-		
-		// Item 2 version shouldn't have changed
-		$json2 = API::getItem($data2['key'], $this, 'json');
-		$this->assertEquals($version2, $json2['version']);
-	}
-	
-	
-	public function test_should_change_case_of_existing_tag() {
-		$data1 = API::createItem("book", [
-			"tags" => [
-				["tag" => "a"],
-			]
-		], $this, 'jsonData');
-		$data2 = API::createItem("book", [
-			"tags" => [
-				["tag" => "a"]
-			]
-		], $this, 'jsonData');
-		$version = $data1['version'];
-		
-		// Change tag case on one item
-		$data1['tags'] = [
-			["tag" => "A"],
-		];
-		
-		$response = API::postItem($data1);
-		$this->assert200($response);
-		$this->assert200ForObject($response);
-		
-		// Item version should be one greater than last update
-		$data1 = API::getItem($data1['key'], $this, 'json')['data'];
-		$data2 = API::getItem($data2['key'], $this, 'json')['data'];
-		$this->assertEquals($version + 1, $data2['version']);
-		$this->assertCount(1, $data1['tags']);
-		$this->assertContains(["tag" => "A"], $data1['tags']);
-		$this->assertContains(["tag" => "a"], $data2['tags']);
-	}
-	
-	
-	public function testTagDiacritics() {
-		$data = API::createItem("book", [
-			"tags" => [
-				["tag" => "ëtest"],
-			]
-		], $this, 'jsonData');
-		$version = $data['version'];
-		
-		// Add 'etest', without accent
-		$data['tags'] = [
-			["tag" => "ëtest"],
-			["tag" => "etest"],
-		];
-		
-		$response = API::postItem($data);
-		$this->assert200($response);
-		$this->assert200ForObject($response);
-		
-		// Item version should be one greater than last update
-		$data = API::getItem($data['key'], $this, 'json')['data'];
-		$this->assertEquals($version + 1, $data['version']);
-		$this->assertCount(2, $data['tags']);
-		$this->assertContains(["tag" => "ëtest"], $data['tags']);
-		$this->assertContains(["tag" => "etest"], $data['tags']);
-	}
-}
diff --git a/tests/remote/tests/API/3/TranslationTest.php b/tests/remote/tests/API/3/TranslationTest.php
deleted file mode 100644
index abf8d373..00000000
--- a/tests/remote/tests/API/3/TranslationTest.php
+++ /dev/null
@@ -1,185 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2014 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class TranslationTests extends APITests {
-	public function setUp() {
-		parent::setUp();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testWebTranslationSingle() {
-		$title = 'Zotero: A Guide for Librarians, Researchers and Educators';
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([
-				"url" => "http://www.amazon.com/Zotero-Guide-Librarians-Researchers-Educators/dp/0838985890/"
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		$this->assert200ForObject($response);
-		$json = API::getJSONFromResponse($response);
-		$itemKey = $json['success'][0];
-		$data = API::getItem($itemKey, $this, 'json')['data'];
-		$this->assertEquals($title, $data['title']);
-	}
-	
-	/**
-	 * @group failing-translation
-	 */
-	/*public function testWebTranslationSingleWithChildItems() {
-		$title = 'A Clustering Approach to Identify Intergenic Non-coding RNA in Mouse Macrophages';
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items",
-			json_encode([
-				"url" => "http://www.computer.org/csdl/proceedings/bibe/2010/4083/00/4083a001-abs.html"
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		$this->assert200ForObject($response, false, 0);
-		$this->assert200ForObject($response, false, 1);
-		$json = API::getJSONFromResponse($response);
-		
-		// Check item
-		$itemKey = $json['success'][0];
-		$data = API::getItem($itemKey, $this, 'json')['data'];
-		$this->assertEquals($title, $data['title']);
-		// NOTE: Tags currently not served via BibTeX (though available in RIS)
-		$this->assertCount(0, $data['tags']);
-		//$this->assertContains(['tag' => 'chip-seq; clustering; non-coding rna; rna polymerase; macrophage', 'type' => 1], $data['tags']); // TODO: split in translator
-		
-		// Check note
-		$itemKey = $json['success'][1];
-		$data = API::getItem($itemKey, $this, 'json')['data'];
-		$this->assertEquals("Complete PDF document was either not available or accessible. "
-			. "Please make sure you're logged in to the digital library to retrieve the "
-			. "complete PDF document.", $data['note']);
-	}*/
-	
-	/**
-	 * @group failing-translation
-	 */
-	public function testWebTranslationMultiple() {
-		$title = 'Zotero: A guide for librarians, researchers, and educators, Second Edition';
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"url" => "http://www.amazon.com/s/field-keywords=zotero+guide+librarians"
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert300($response);
-		$json = json_decode($response->getBody());
-		
-		$results = get_object_vars($json->items);
-		$key = array_keys($results)[0];
-		$val = array_values($results)[0];
-		$this->assertEquals('0', $key);
-		$this->assertEquals($title, $val);
-		
-		$items = new \stdClass;
-		$items->$key = $val;
-		
-		// Missing token
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"url" => "http://www.amazon.com/s/field-keywords=zotero+guide+librarians",
-				"items" => $items
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert400($response, "Token not provided with selected items");
-		
-		// Invalid selection
-		$items2 = clone $items;
-		$invalidKey = "12345";
-		$items2->$invalidKey = $items2->$key;
-		unset($items2->$key);
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"url" => "http://www.amazon.com/s/field-keywords=zotero+guide+librarians",
-				"token" => $json->token,
-				"items" => $items2
-			]),
-			array("Content-Type: application/json")
-		);
-		$this->assert400($response, "Index '$invalidKey' not found for URL and token");
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"url" => "http://www.amazon.com/s/field-keywords=zotero+guide+librarians",
-				"token" => $json->token,
-				"items" => $items
-			]),
-			array("Content-Type: application/json")
-		);
-		
-		$this->assert200($response);
-		$this->assert200ForObject($response);
-		$json = API::getJSONFromResponse($response);
-		$itemKey = $json['success'][0];
-		$data = API::getItem($itemKey, $this, 'json')['data'];
-		$this->assertEquals($title, $data['title']);
-	}
-	
-	
-	public function testWebTranslationInvalidToken() {
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"url" => "http://www.amazon.com/s/field-keywords=zotero+guide+librarians",
-				"token" => md5(uniqid())
-			]),
-			["Content-Type: application/json"]
-		);
-		$this->assert400($response, "'token' is valid only for item selection requests");
-	}
-}
diff --git a/tests/remote/tests/API/3/VersionTest.php b/tests/remote/tests/API/3/VersionTest.php
deleted file mode 100644
index 135dfcec..00000000
--- a/tests/remote/tests/API/3/VersionTest.php
+++ /dev/null
@@ -1,1148 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-namespace APIv3;
-use API3 as API;
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-
-class VersionTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public function setUp() {
-		parent::setUp();
-		API::userClear(self::$config['userID']);
-	}
-	
-	
-	public function testSingleObjectLastModifiedVersion() {
-		$this->_testSingleObjectLastModifiedVersion('collection');
-		$this->_testSingleObjectLastModifiedVersion('item');
-		$this->_testSingleObjectLastModifiedVersion('search');
-	}
-	
-	
-	public function testMultiObjectLastModifiedVersion() {
-		$this->_testMultiObjectLastModifiedVersion('collection');
-		$this->_testMultiObjectLastModifiedVersion('item');
-		$this->_testMultiObjectLastModifiedVersion('search');
-	}
-	
-	
-	public function testMultiObject304NotModified() {
-		$this->_testMultiObject304NotModified('collection');
-		$this->_testMultiObject304NotModified('item');
-		$this->_testMultiObject304NotModified('search');
-		$this->_testMultiObject304NotModified('setting');
-		$this->_testMultiObject304NotModified('tag');
-	}
-	
-	
-	public function testSinceAndVersionsFormat() {
-		$this->_testSinceAndVersionsFormat('collection', 'since');
-		$this->_testSinceAndVersionsFormat('item', 'since');
-		$this->_testSinceAndVersionsFormat('search', 'since');
-		API::userClear(self::$config['userID']);
-		$this->_testSinceAndVersionsFormat('collection', 'newer');
-		$this->_testSinceAndVersionsFormat('item', 'newer');
-		$this->_testSinceAndVersionsFormat('search', 'newer');
-	}
-	
-	
-	public function testUploadUnmodified() {
-		$this->_testUploadUnmodified('collection');
-		$this->_testUploadUnmodified('item');
-		$this->_testUploadUnmodified('search');
-	}
-	
-	
-	public function testTagsSince() {
-		self::_testTagsSince('since');
-		API::userClear(self::$config['userID']);
-		self::_testTagsSince('newer');
-	}
-	
-	
-	
-	public function test_should_not_include_library_version_for_400() {
-		$json = API::createItem("book", [], $this, 'json');
-		$libraryVersion = $json['version'];
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/" . $json['key'],
-			json_encode($json),
-			[
-				"Content-Type: application/json",
-				// 400 due to version property mismatch
-				"If-Unmodified-Since-Version: " . ($json['version'] - 1)
-			]
-		);
-		$this->assert400($response);
-		$this->assertNull($response->getHeader('Last-Modified-Version'));
-	}
-	
-	public function test_should_include_library_version_for_412() {
-		$json = API::createItem("book", [], $this, 'json');
-		$libraryVersion = $json['version'];
-		$json['data']['version']--;
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/" . $json['key'],
-			json_encode($json),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: " . ($json['version'] - 1)
-			]
-		);
-		$this->assert412($response);
-		$this->assertLastModifiedVersion($libraryVersion, $response);
-	}
-	
-	
-	private function _testSingleObjectLastModifiedVersion($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		$keyProp = $objectType . "Key";
-		$versionProp = $objectType . "Version";
-		
-		switch ($objectType) {
-		case 'collection':
-			$objectKey = API::createCollection("Name", false, $this, 'key');
-			break;
-		
-		case 'item':
-			$objectKey = API::createItem("book", array("title" => "Title"), $this, 'key');
-			break;
-		
-		case 'search':
-			$objectKey = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this,
-				'key'
-			);
-			break;
-		}
-		
-		// JSON: Make sure all three instances of the object version
-		// (Last-Modified-Version, 'version', and data.version)
-		// match the library version
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey"
-		);
-		$this->assert200($response);
-		$objectVersion = $response->getHeader("Last-Modified-Version");
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($objectVersion, $json['version']);
-		$this->assertEquals($objectVersion, $json['data']['version']);
-		
-		// Atom: Make sure all three instances of the object version
-		// (Last-Modified-Version, zapi:version, and the JSON
-		// {$objectType}Version property match the library version
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?content=json"
-		);
-		$this->assert200($response);
-		$objectVersion = $response->getHeader("Last-Modified-Version");
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertEquals($objectVersion, $json['version']);
-		$this->assertEquals($objectVersion, $data['version']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?limit=1"
-		);
-		$this->assert200($response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		
-		$this->assertEquals($libraryVersion, $objectVersion);
-		
-		$this->_modifyJSONObject($objectType, $json);
-		
-		// No If-Unmodified-Since-Version or JSON version property
-		unset($json['version']);
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($json)
-		);
-		$this->assert428($response);
-		
-		// Out of date version
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($json),
-			array(
-				"If-Unmodified-Since-Version: " . ($objectVersion - 1)
-			)
-		);
-		$this->assert412($response);
-		
-		// Update with version header
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($json),
-			array(
-				"If-Unmodified-Since-Version: " . $objectVersion
-			)
-		);
-		$this->assert204($response);
-		$newObjectVersion = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($objectVersion, $newObjectVersion);
-		
-		// Update object with JSON version property
-		$this->_modifyJSONObject($objectType, $json);
-		$json['version'] = $newObjectVersion;
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			json_encode($json)
-		);
-		$this->assert204($response);
-		$newObjectVersion2 = $response->getHeader("Last-Modified-Version");
-		$this->assertGreaterThan($newObjectVersion, $newObjectVersion2);
-		
-		// Make sure new library version matches new object version
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?limit=1"
-		);
-		$this->assert200($response);
-		$newLibraryVersion = $response->getHeader("Last-Modified-Version");
-		$this->assertEquals($newObjectVersion2, $newLibraryVersion);
-		return;
-		
-		// Create an item to increase the library version, and make sure
-		// original object version stays the same
-		API::createItem("book", array("title" => "Title"), $this, 'key');
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey?limit=1"
-		);
-		$this->assert200($response);
-		$newObjectVersion2 = $response->getHeader("Last-Modified-Version");
-		$this->assertEquals($newLibraryVersion, $newObjectVersion2);
-		
-		//
-		// Delete object
-		//
-		
-		// No If-Unmodified-Since-Version
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey"
-		);
-		$this->assert428($response);
-		
-		// Outdated If-Unmodified-Since-Version
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			array(
-				"If-Unmodified-Since-Version: " . $objectVersion
-			)
-		);
-		$this->assert412($response);
-		
-		// Delete object
-		$response = API::userDelete(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey",
-			array(
-				"If-Unmodified-Since-Version: " . $newObjectVersion2
-			)
-		);
-		$this->assert204($response);
-	}
-	
-	
-	private function _modifyJSONObject($objectType, &$json) {
-		// Modifying object should increase its version
-		switch ($objectType) {
-		case 'collection':
-			$json['name'] = "New Name " . uniqid();
-			break;
-		
-		case 'item':
-			$json['title'] = "New Title" . uniqid();
-			break;
-		
-		case 'search':
-			$json['name'] = "New Name" . uniqid();
-			break;
-		}
-	}
-	
-	
-	private function _testMultiObjectLastModifiedVersion($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?limit=1"
-		);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version));
-		
-		switch ($objectType) {
-		case 'collection':
-			$json = new \stdClass();
-			$json->name = "Name";
-			break;
-		
-		case 'item':
-			$json = API::getItemTemplate("book");
-			break;
-		
-		case 'search':
-			$json = new \stdClass();
-			$json->name = "Name";
-			$json->conditions = array(
-				array(
-					"condition" => "title",
-					"operator" => "contains",
-					"value" => "test"
-				)
-			);
-			break;
-		}
-		
-		// Outdated library version
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode(array(
-				$objectTypePlural => array($json)
-			)),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: " . ($version - 1)
-			)
-		);
-		$this->assert412($response);
-		
-		// Make sure version didn't change during failure
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?limit=1"
-		);
-		$this->assertEquals($version, $response->getHeader("Last-Modified-Version"));
-		
-		// Create a new object, using library timestamp
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json]),
-			array(
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: $version"
-			)
-		);
-		$this->assert200($response);
-		$version2 = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version2));
-		// Version should be incremented on new object
-		$this->assertGreaterThan($version, $version2);
-		$objectKey = API::getFirstSuccessKeyFromResponse($response);
-		
-		// Check single-object request
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey"
-		);
-		$this->assert200($response);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version));
-		$this->assertEquals($version, $version2);
-		$json = API::getJSONFromResponse($response)['data'];
-		
-		// Modify object
-		$json['key'] = $objectKey;
-		switch ($objectType) {
-		case 'collection':
-			$json['name'] = "New Name";
-			break;
-		
-		case 'item':
-			$json['title'] = "New Title";
-			break;
-		
-		case 'search':
-			$json['name'] = "New Name";
-			break;
-		}
-		
-		// No If-Unmodified-Since-Version or object version property
-		unset($json['version']);
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert428ForObject($response);
-		
-		// Outdated object version property
-		$json['version'] = $version - 1;
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json]),
-			array(
-				"Content-Type: application/json"
-			)
-		);
-		$this->assert412ForObject($response, ucwords($objectType)
-			. " has been modified since specified version "
-			. "(expected {$json['version']}, found $version)");
-		
-		// Modify object, using object version property
-		$json['version'] = $version;
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert200($response);
-		// Version should be incremented on modified object
-		$version3 = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version3));
-		$this->assertGreaterThan($version2, $version3);
-		
-		// Check library version
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural"
-		);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version));
-		$this->assertEquals($version, $version3);
-		
-		// Check single-object request
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural/$objectKey"
-		);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version));
-		$this->assertEquals($version, $version3);
-		
-		// TODO: Version should be incremented on deleted item
-	}
-	
-	
-	//
-	// PATCH (single object)
-	//
-	
-	// PATCH to a missing object without a version is a 404
-	public function testPatchMissingObjectWithoutVersion() {
-		$this->_testPatchMissingObjectWithoutVersion('collection');
-		$this->_testPatchMissingObjectWithoutVersion('item');
-		$this->_testPatchMissingObjectWithoutVersion('search');
-	}
-	
-	private function _testPatchMissingObjectWithoutVersion($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$json = API::createUnsavedDataObject($objectType);
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"$objectTypePlural/TPMBJWNV",
-			json_encode($json),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert404($response);
-	}
-	
-	
-	// PATCH to an existing object without a version is a 428 Precondition Required
-	public function testPatchExistingObjectWithoutVersion() {
-		//$this->_testPatchExistingObjectWithoutVersion('collection');
-		//$this->_testPatchExistingObjectWithoutVersion('item');
-		$this->_testPatchExistingObjectWithoutVersion('search');
-	}
-	
-	private function _testPatchExistingObjectWithoutVersion($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$key = API::createDataObject($objectType, 'key');
-		$json = API::createUnsavedDataObject($objectType);
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"$objectTypePlural/$key",
-			json_encode($json),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert428($response);
-	}
-	
-	
-	// PATCH with version header > 0 to a missing object is a 404
-	public function testPatchMissingObjectWithVersionHeader() {
-		$this->_testPatchMissingObjectWithVersionHeader('collection');
-		$this->_testPatchMissingObjectWithVersionHeader('item');
-		$this->_testPatchMissingObjectWithVersionHeader('search');
-	}
-	
-	private function _testPatchMissingObjectWithVersionHeader($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$json = API::createUnsavedDataObject($objectType);
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"$objectTypePlural/TPMBJWVH",
-			json_encode($json),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: 123"
-			]
-		);
-		$this->assert404($response);
-	}
-	
-	
-	// PATCH with version property > 0 to a missing object is a 404
-	public function testPatchMissingObjectWithVersionProperty() {
-		$this->_testPatchMissingObjectWithVersionProperty('collection');
-		$this->_testPatchMissingObjectWithVersionProperty('item');
-		$this->_testPatchMissingObjectWithVersionProperty('search');
-	}
-	
-	private function _testPatchMissingObjectWithVersionProperty($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$json = API::createUnsavedDataObject($objectType);
-		$json['version'] = 123;
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"$objectTypePlural/TPMBJWVP",
-			json_encode($json),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert404($response);
-	}
-	
-	
-	// PATCH to a missing object with version 0 header is a 204
-	public function testPatchMissingObjectWithVersion0Header() {
-		$this->_testPatchMissingObjectWithVersion0Header('collection');
-		$this->_testPatchMissingObjectWithVersion0Header('item');
-		$this->_testPatchMissingObjectWithVersion0Header('search');
-	}
-	
-	private function _testPatchMissingObjectWithVersion0Header($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$json = API::createUnsavedDataObject($objectType);
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"$objectTypePlural/TPMBWVZH",
-			json_encode($json),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: 0"
-			]
-		);
-		$this->assert204($response);
-	}
-	
-	
-	// PATCH to a missing object with version 0 property is a 204
-	public function testPatchMissingObjectWithVersion0Property() {
-		$this->_testPatchMissingObjectWithVersion0Property('collection');
-		$this->_testPatchMissingObjectWithVersion0Property('item');
-		$this->_testPatchMissingObjectWithVersion0Property('search');
-	}
-	
-	private function _testPatchMissingObjectWithVersion0Property($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$json = API::createUnsavedDataObject($objectType);
-		$json['version'] = 0;
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"$objectTypePlural/TPMBWVZP",
-			json_encode($json),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert204($response);
-	}
-	
-	
-	// PATCH to an existing object with version header 0 is 412
-	public function testPatchExistingObjectWithVersion0Header() {
-		$this->_testPatchExistingObjectWithVersion0Header('collection');
-		$this->_testPatchExistingObjectWithVersion0Header('item');
-		$this->_testPatchExistingObjectWithVersion0Header('search');
-	}
-	
-	private function _testPatchExistingObjectWithVersion0Header($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$key = API::createDataObject($objectType, 'key');
-		$json = API::createUnsavedDataObject($objectType);
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"$objectTypePlural/$key",
-			json_encode($json),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: 0"
-			]
-		);
-		$this->assert412($response);
-	}
-	
-	
-	// PATCH to an existing object with version property 0 is 412
-	public function testPatchExistingObjectWithVersion0Property() {
-		$this->_testPatchExistingObjectWithVersion0Property('collection');
-		$this->_testPatchExistingObjectWithVersion0Property('item');
-		$this->_testPatchExistingObjectWithVersion0Property('search');
-	}
-	
-	private function _testPatchExistingObjectWithVersion0Property($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$key = API::createDataObject($objectType, 'key');
-		$json = API::createUnsavedDataObject($objectType);
-		$json['version'] = 0;
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"$objectTypePlural/$key",
-			json_encode($json),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert412($response);
-	}
-	
-	
-	// PATCH to an existing object with version header < current version is 412
-	public function testPatchExistingObjectWithOldVersionHeader() {
-		$this->_testPatchExistingObjectWithOldVersionHeader('collection');
-		$this->_testPatchExistingObjectWithOldVersionHeader('item');
-		$this->_testPatchExistingObjectWithOldVersionHeader('search');
-	}
-	
-	private function _testPatchExistingObjectWithOldVersionHeader($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$key = API::createDataObject($objectType, 'key');
-		$json = API::createUnsavedDataObject($objectType);
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"$objectTypePlural/$key",
-			json_encode($json),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: 1"
-			]
-		);
-		$this->assert412($response);
-	}
-	
-	
-	// PATCH to an existing object with version property < current version is 412
-	public function testPatchExistingObjectWithOldVersionProperty() {
-		$this->_testPatchExistingObjectWithOldVersionProperty('collection');
-		$this->_testPatchExistingObjectWithOldVersionProperty('item');
-		$this->_testPatchExistingObjectWithOldVersionProperty('search');
-	}
-	
-	private function _testPatchExistingObjectWithOldVersionProperty($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$key = API::createDataObject($objectType, 'key');
-		$json = API::createUnsavedDataObject($objectType);
-		$json['version'] = 1;
-		
-		$response = API::userPatch(
-			self::$config['userID'],
-			"$objectTypePlural/$key",
-			json_encode($json),
-			[
-				"Content-Type: application/json"
-			]
-		);
-		$this->assert412($response);
-	}
-	
-	
-	//
-	// PATCH (multiple objects)
-	//
-	
-	// POST with a version 0 header to an existing library is a 412
-	public function testPostExistingLibraryWithVersion0Header() {
-		$this->_testPostExistingLibraryWithVersion0Header('collection');
-		$this->_testPostExistingLibraryWithVersion0Header('item');
-		$this->_testPostExistingLibraryWithVersion0Header('search');
-	}
-	
-	private function _testPostExistingLibraryWithVersion0Header($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$json = API::createUnsavedDataObject($objectType);
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			$objectTypePlural,
-			json_encode([$json]),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: 0"
-			]
-		);
-		$this->assert412($response);
-	}
-	
-	// POST to a missing object with a version property of 0 is a 204 for that object
-	public function testPatchMissingObjectsWithVersion0Property() {
-		$this->_testPatchMissingObjectsWithVersion0Property('collection');
-		$this->_testPatchMissingObjectsWithVersion0Property('item');
-		$this->_testPatchMissingObjectsWithVersion0Property('search');
-	}
-	
-	private function _testPatchMissingObjectsWithVersion0Property($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$json = API::createUnsavedDataObject($objectType);
-		$json['key'] = 'TPMSWVZP';
-		$json['version'] = 0;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert200ForObject($response);
-	}
-	
-	
-	// POST with version > 0 to a missing object is a 404 for that object
-	public function testPatchMissingObjectsWithVersion() {
-		$this->_testPatchMissingObjectsWithVersion('collection');
-		$this->_testPatchMissingObjectsWithVersion('item');
-		$this->_testPatchMissingObjectsWithVersion('search');
-	}
-	
-	private function _testPatchMissingObjectsWithVersion($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$json = API::createUnsavedDataObject($objectType);
-		$json['key'] = 'TPMBJSWV';
-		$json['version'] = 123;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert404ForObject($response, ucwords($objectType)
-			. " doesn't exist (expected version 123; use 0 instead)");
-	}
-	
-	// POST to an existing object with a version prop of 0 is a 412 for that object
-	public function testPatchExistingObjectsWithVersion0Property() {
-		$this->_testPatchExistingObjectsWithVersion0Property('collection');
-		$this->_testPatchExistingObjectsWithVersion0Property('item');
-		$this->_testPatchExistingObjectsWithVersion0Property('search');
-	}
-	
-	private function _testPatchExistingObjectsWithVersion0Property($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$key = API::createDataObject($objectType, 'key');
-		$json = API::createUnsavedDataObject($objectType);
-		$json['key'] = $key;
-		$json['version'] = 0;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert412ForObject($response);
-	}
-	
-	
-	// POST to an existing object without a version prop but with a header is a 428 for that object
-	public function testPatchExistingObjectsWithoutVersionWithHeader() {
-		$this->_testPatchExistingObjectsWithoutVersionWithHeader('collection');
-		$this->_testPatchExistingObjectsWithoutVersionWithHeader('item');
-		$this->_testPatchExistingObjectsWithoutVersionWithHeader('search');
-	}
-	
-	private function _testPatchExistingObjectsWithoutVersionWithHeader($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$existing = API::createDataObject($objectType, 'json');
-		$key = $existing['key'];
-		$libraryVersion = $existing['version'];
-		$json = API::createUnsavedDataObject($objectType);
-		$json['key'] = $key;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert428ForObject($response);
-	}
-	
-	
-	// POST to an existing object without a version prop and without a header is a 428 for that object
-	public function testPatchExistingObjectsWithoutVersionWithoutHeader() {
-		$this->_testPatchExistingObjectsWithoutVersionWithoutHeader('collection');
-		$this->_testPatchExistingObjectsWithoutVersionWithoutHeader('item');
-		$this->_testPatchExistingObjectsWithoutVersionWithoutHeader('search');
-	}
-	
-	private function _testPatchExistingObjectsWithoutVersionWithoutHeader($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$key = API::createDataObject($objectType, 'key');
-		$json = API::createUnsavedDataObject($objectType);
-		$json['key'] = $key;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert428ForObject($response);
-	}
-	
-	
-	// should return 412 for POST to /settings with outdated version header
-	public function testPostToSettingsWithOutdatedVersionHeader() {
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Outdated library version
-		$response = API::userPost(
-			self::$config['userID'],
-			"settings",
-			json_encode([]),
-			[
-				"Content-Type: application/json",
-				"If-Unmodified-Since-Version: " . ($libraryVersion - 1)
-			]
-		);
-		$this->assert412($response);
-	}
-	
-	
-	// POST to an existing object with a version prop < current version is a 412 for that object
-	public function testPatchExistingObjectsWithOldVersion0Property() {
-		$this->_testPatchExistingObjectsWithOldVersionProperty('collection');
-		$this->_testPatchExistingObjectsWithOldVersionProperty('item');
-		$this->_testPatchExistingObjectsWithOldVersionProperty('search');
-	}
-	
-	private function _testPatchExistingObjectsWithOldVersionProperty($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$key = API::createDataObject($objectType, 'key');
-		$json = API::createUnsavedDataObject($objectType);
-		$json['key'] = $key;
-		$json['version'] = 1;
-		
-		$response = API::userPost(
-			self::$config['userID'],
-			"$objectTypePlural",
-			json_encode([$json]),
-			array("Content-Type: application/json")
-		);
-		$this->assert412ForObject($response);
-	}
-	
-	
-	
-	private function _testMultiObject304NotModified($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural"
-		);
-		$version = $response->getHeader("Last-Modified-Version");
-		$this->assertTrue(is_numeric($version));
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural",
-			array(
-				"If-Modified-Since-Version: $version"
-			)
-		);
-		$this->assert304($response);
-	}
-	
-	
-	private function _testSinceAndVersionsFormat($objectType, $sinceParam) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$dataArray = [];
-		
-		switch ($objectType) {
-		case 'collection':
-			$dataArray[] = API::createCollection("Name", false, $this, 'jsonData');
-			$dataArray[] = API::createCollection("Name", false, $this, 'jsonData');
-			$dataArray[] = API::createCollection("Name", false, $this, 'jsonData');
-			break;
-		
-		case 'item':
-			$dataArray[] = API::createItem("book", array("title" => "Title"), $this, 'jsonData');
-			$dataArray[] = API::createNoteItem("Foo", $dataArray[0]['key'], $this, 'jsonData');
-			$dataArray[] = API::createItem("book", array("title" => "Title"), $this, 'jsonData');
-			$dataArray[] = API::createItem("book", array("title" => "Title"), $this, 'jsonData');
-			break;
-		
-		
-		case 'search':
-			$dataArray[] = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this,
-				'jsonData'
-			);
-			$dataArray[] = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this,
-				'jsonData'
-			);
-			$dataArray[] = API::createSearch(
-				"Name",
-				array(
-					array(
-						"condition" => "title",
-						"operator" => "contains",
-						"value" => "test"
-					)
-				),
-				$this,
-				'jsonData'
-			);
-		}
-		
-		$objects = $dataArray;
-		
-		$firstVersion = $objects[0]['version'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?format=versions&$sinceParam=$firstVersion"
-		);
-		
-		$this->assert200($response);
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertCount(sizeOf($objects) - 1, $json);
-		$keys = array_keys($json);
-		
-		if ($objectType == 'item') {
-			$this->assertEquals($objects[3]['key'], array_shift($keys));
-			$this->assertEquals($objects[3]['version'], array_shift($json));
-		}
-		$this->assertEquals($objects[2]['key'], array_shift($keys));
-		$this->assertEquals($objects[2]['version'], array_shift($json));
-		$this->assertEquals($objects[1]['key'], array_shift($keys));
-		$this->assertEquals($objects[1]['version'], array_shift($json));
-		$this->assertEmpty($json);
-		
-		// Test /top for items
-		if ($objectType == 'item') {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items/top?format=versions&$sinceParam=$firstVersion"
-			);
-			
-			$this->assert200($response);
-			$json = json_decode($response->getBody(), true);
-			$this->assertNotNull($json);
-			$this->assertCount(sizeOf($objects) - 2, $json); // Exclude first item and child
-			$keys = array_keys($json);
-			
-			$objects = $dataArray;
-			
-			$this->assertEquals($objects[3]['key'], array_shift($keys));
-			$this->assertEquals($objects[3]['version'], array_shift($json));
-			$this->assertEquals($objects[2]['key'], array_shift($keys));
-			$this->assertEquals($objects[2]['version'], array_shift($json));
-			$this->assertEmpty($json);
-		}
-	}
-	
-	private function _testUploadUnmodified($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		switch ($objectType) {
-		case 'collection':
-			$data = API::createCollection("Name", false, $this, 'jsonData');
-			break;
-		
-		case 'item':
-			$data = API::createItem("book", array("title" => "Title"), $this, 'jsonData');
-			break;
-		
-		case 'search':
-			$data = API::createSearch("Name", 'default', $this, 'jsonData');
-			break;
-		}
-		
-		$version = $data['version'];
-		$this->assertNotEquals(0, $version);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"$objectTypePlural/{$data['key']}",
-			json_encode($data)
-		);
-		$this->assert204($response);
-		$this->assertEquals($version, $response->getHeader("Last-Modified-Version"));
-		
-		switch ($objectType) {
-		case 'collection':
-			$json = API::getCollection($data['key'], $this, 'json');
-			break;
-		
-		case 'item':
-			$json = API::getItem($data['key'], $this, 'json');
-			break;
-		
-		case 'search':
-			$json = API::getSearch($data['key'], $this, 'json');
-			break;
-		}
-		$this->assertEquals($version, $json['version']);
-	}
-	
-	
-	private function _testTagsSince($param) {
-		$tags1 = array("a", "aa", "b");
-		$tags2 = array("b", "c", "cc");
-		
-		$data1 = API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags1)
-		), $this, 'jsonData');
-		
-		$data2 = API::createItem("book", array(
-			"tags" => array_map(function ($tag) {
-				return array("tag" => $tag);
-			}, $tags2)
-		), $this, 'jsonData');
-		
-		// Only newly added tags should be included in 'since',
-		// not previously added tags or tags added to items
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?$param=" . $data1['version']
-		);
-		$this->assertNumResults(2, $response);
-		
-		// Deleting an item shouldn't update associated tag versions
-		$response = API::userDelete(
-			self::$config['userID'],
-			"items/{$data1['key']}",
-			array(
-				"If-Unmodified-Since-Version: " . $data1['version']
-			)
-		);
-		$this->assert204($response);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?$param=" . $data1['version']
-		);
-		$this->assertNumResults(2, $response);
-		$libraryVersion = $response->getHeader("Last-Modified-Version");
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"tags?$param=" . $libraryVersion
-		);
-		$this->assertNumResults(0, $response);
-	}
-}
diff --git a/tests/remote/tests/API/APITests.inc.php b/tests/remote/tests/API/APITests.inc.php
deleted file mode 100644
index 5f1d234a..00000000
--- a/tests/remote/tests/API/APITests.inc.php
+++ /dev/null
@@ -1,92 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-require_once 'include/bootstrap.inc.php';
-
-//
-// Helper functions
-//
-class APITests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $nsZAPI;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-	}
-	
-	
-	public function setUp() {
-	}
-	
-	
-	public function test() {}
-	
-	
-	protected function assertContentType($contentType, $response) {
-		try {
-			$this->assertEquals($contentType, $response->getHeader("Content-Type"));
-		}
-		catch (Exception $e) {
-			echo "\n" . $response->getBody() . "\n";
-			throw ($e);
-		}
-	}
-	
-	
-	protected function assertHTTPStatus($status, $response) {
-		try {
-			$this->assertEquals($status, $response->getStatus());
-		}
-		catch (Exception $e) {
-			echo "\n" . $response->getBody() . "\n";
-			throw ($e);
-		}
-	}
-	
-	
-	protected function assertCompression($response) {
-		$this->assertEquals('gzip', $response->getHeader('Content-Encoding'));
-	}
-	
-	
-	protected function assertNoCompression($response) {
-		$this->assertNull($response->getHeader('Content-Encoding'));
-	}
-	
-	
-	protected function assertContentLength($length, $response) {
-		$this->assertEquals($length, $response->getHeader('Content-Length'));
-	}
-	
-	
-	protected function assertISO8601Date($date) {
-		$this->assertTrue(\Zotero_Date::isISO8601($date));
-	}
-}
-
diff --git a/tests/remote/tests/API/GeneralTest.php b/tests/remote/tests/API/GeneralTest.php
deleted file mode 100644
index 0d4552b7..00000000
--- a/tests/remote/tests/API/GeneralTest.php
+++ /dev/null
@@ -1,238 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-require_once 'APITests.inc.php';
-require_once 'include/api3.inc.php';
-use API3 as API;
-
-class GeneralTests extends APITests {
-	public static function setUpBeforeClass() {
-		parent::setUpBeforeClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public static function tearDownAfterClass() {
-		parent::tearDownAfterClass();
-		API::userClear(self::$config['userID']);
-	}
-	
-	public function setUp() {
-		parent::setUp();
-		API::useAPIKey(self::$config['apiKey']);
-		API::useAPIVersion(false);
-	}
-	
-	public function testAPIVersionHeader() {
-		$minVersion = 1;
-		$maxVersion = 3;
-		$defaultVersion = 3;
-		
-		for ($i = $minVersion; $i <= $maxVersion; $i++) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items?format=keys&limit=1",
-				[
-					"Zotero-API-Version: $i"
-				]
-			);
-			$this->assertEquals($i, $response->getHeader("Zotero-API-Version"));
-		}
-		
-		// Default
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?format=keys&limit=1"
-		);
-		$this->assertEquals($defaultVersion, $response->getHeader("Zotero-API-Version"));
-	}
-	
-	
-	public function testAPIVersionParameter() {
-		$minVersion = 1;
-		$maxVersion = 3;
-		
-		for ($i = $minVersion; $i <= $maxVersion; $i++) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items?format=keys&limit=1&v=$i"
-			);
-			$this->assertEquals($i, $response->getHeader("Zotero-API-Version"));
-		}
-	}
-	
-	
-	public function testAuthorization() {
-		$apiKey = self::$config['apiKey'];
-		API::useAPIKey(false);
-		
-		// Zotero-API-Key header
-		$response = API::userGet(
-			self::$config['userID'],
-			"items",
-			[
-				"Zotero-API-Key: $apiKey"
-			]
-		);
-		$this->assertHTTPStatus(200, $response);
-		
-		// Authorization header
-		$response = API::userGet(
-			self::$config['userID'],
-			"items",
-			[
-				"Authorization: Bearer $apiKey"
-			]
-		);
-		$this->assertHTTPStatus(200, $response);
-		
-		// Query parameter
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=$apiKey"
-		);
-		$this->assertHTTPStatus(200, $response);
-		
-		// Zotero-API-Key header and query parameter
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=$apiKey",
-			[
-				"Zotero-API-Key: $apiKey"
-			]
-		);
-		$this->assertHTTPStatus(200, $response);
-		
-		// No key
-		$response = API::userGet(
-			self::$config['userID'],
-			"items"
-		);
-		$this->assertHTTPStatus(403, $response);
-		
-		// Zotero-API-Key header and empty key (which is still an error)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=",
-			[
-				"Zotero-API-Key: $apiKey"
-			]
-		);
-		$this->assertHTTPStatus(400, $response);
-		
-		// Zotero-API-Key header and incorrect Authorization key (which is ignored)
-		$response = API::userGet(
-			self::$config['userID'],
-			"items",
-			[
-				"Zotero-API-Key: $apiKey",
-				"Authorization: Bearer invalidkey"
-			]
-		);
-		$this->assertHTTPStatus(200, $response);
-		
-		// Zotero-API-Key header and key mismatch
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=invalidkey",
-			[
-				"Zotero-API-Key: $apiKey"
-			]
-		);
-		$this->assertHTTPStatus(400, $response);
-		
-		// Invalid Bearer format
-		$response = API::userGet(
-			self::$config['userID'],
-			"items",
-			[
-				"Authorization: Bearer key=$apiKey"
-			]
-		);
-		$this->assertHTTPStatus(400, $response);
-		
-		// Ignored OAuth 1.0 header, with key query parameter
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=$apiKey",
-			[
-				'Authorization: OAuth oauth_consumer_key="aaaaaaaaaaaaaaaaaaaa"'
-			]
-		);
-		$this->assertHTTPStatus(200, $response);
-		
-		// Ignored OAuth 1.0 header, with no key query parameter
-		$response = API::userGet(
-			self::$config['userID'],
-			"items",
-			[
-				'Authorization: OAuth oauth_consumer_key="aaaaaaaaaaaaaaaaaaaa"'
-			]
-		);
-		$this->assertHTTPStatus(403, $response);
-	}
-	
-	
-	public function testCORS() {
-		$response = HTTP::options(
-			self::$config['apiURLPrefix'],
-			[
-				'Origin: http://example.com'
-			]
-		);
-		$this->assertHTTPStatus(200, $response);
-		$this->assertEquals('', $response->getBody());
-		$this->assertEquals('*', $response->getHeader('Access-Control-Allow-Origin'));
-	}
-	
-	
-	public function test200Compression() {
-		$response = API::get("itemTypes");
-		$this->assertHTTPStatus(200, $response);
-		$this->assertCompression($response);
-	}
-	
-	
-	public function test404Compression() {
-		$response = API::get("invalidurl");
-		$this->assertHTTPStatus(404, $response);
-		$this->assertCompression($response);
-	}
-	
-	
-	public function test204NoCompression() {
-		$json = API::createItem("book", [], null, 'jsonData');
-		$response = API::userDelete(
-			self::$config['userID'],
-			"items/{$json['key']}",
-			[
-				"If-Unmodified-Since-Version: {$json['version']}"
-			]
-		);
-		$this->assertHTTPStatus(204, $response);
-		$this->assertNoCompression($response);
-		$this->assertContentLength(0, $response);
-	}
-}
diff --git a/tests/remote/tests/Sync/CollectionTest.php b/tests/remote/tests/Sync/CollectionTest.php
deleted file mode 100644
index 35c48e01..00000000
--- a/tests/remote/tests/Sync/CollectionTest.php
+++ /dev/null
@@ -1,144 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-use API2 as API;
-require_once 'include/bootstrap.inc.php';
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class SyncCollectionTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-		API::groupClear(self::$config['ownedPublicGroupID']);
-		self::$sessionID = Sync::login();
-	}
-	
-	
-	public function tearDown() {
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-	}
-	
-	
-	public function testCollectionItemUpdate() {
-		$collectionKey = Sync::createCollection(
-			self::$sessionID, self::$config['libraryID'], "Test", null, $this
-		);
-		$itemKey = Sync::createItem(
-			self::$sessionID, self::$config['libraryID'], "book", null, $this
-		);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Get the item version
-		$itemXML = API::getItemXML($itemKey);
-		$data = API::parseDataFromAtomEntry($itemXML);
-		$json = json_decode($data['content'], true);
-		$itemVersion = $json['itemVersion'];
-		$this->assertNotNull($itemVersion);
-		
-		// Add via sync
-		$collectionXML = $xml->updated[0]->collections[0]->collection[0];
-		$collectionXML['libraryID'] = self::$config['libraryID'];
-		$collectionXML->addChild("items", $itemKey);
-		
-		$data = '<data version="9"><collections>'
-			. $collectionXML->asXML()
-			. '</collections>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Make sure item was updated
-		$itemXML = API::getItemXML($itemKey);
-		$data = API::parseDataFromAtomEntry($itemXML);
-		$json = json_decode($data['content'], true);
-		$this->assertGreaterThan($itemVersion, $json['itemVersion']);
-		$itemVersion = $json['itemVersion'];
-		$this->assertCount(1, $json['collections']);
-		$this->assertContains($collectionKey, $json['collections']);
-		
-		// Remove via sync
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$collectionXML = $xml->updated[0]->collections[0]->collection[0];
-		$collectionXML['libraryID'] = self::$config['libraryID'];
-		unset($collectionXML->items);
-		
-		$data = '<data version="9"><collections>'
-			. $collectionXML->asXML()
-			. '</collections>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Make sure item was removed
-		$itemXML = API::getItemXML($itemKey);
-		$data = API::parseDataFromAtomEntry($itemXML);
-		$json = json_decode($data['content'], true);
-		$this->assertGreaterThan($itemVersion, $json['itemVersion']);
-		$this->assertCount(0, $json['collections']);
-	}
-	
-	
-	public function testCollectionNameTooLong() {
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$content = str_repeat("1", 256);
-		
-		// Create too-long note via sync
-		$data = '<data version="9"><collections>'
-			. '<collection libraryID="' . self::$config['libraryID'] . '" key="AAAAAAAA" '
-			. 'dateAdded="2009-03-07 04:53:20" dateModified="2009-03-07 04:54:09" '
-			. 'name="' . $content . '"/>'
-			. '</collections></data>';
-		
-		Sync::useZoteroVersion();
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("COLLECTION_TOO_LONG", $xml->error["code"]);
-		$this->assertRegExp('/^Collection \'.+\' too long/', (string) $xml->error);
-	}
-}
diff --git a/tests/remote/tests/Sync/CreatorTest.php b/tests/remote/tests/Sync/CreatorTest.php
deleted file mode 100644
index 3d15dbea..00000000
--- a/tests/remote/tests/Sync/CreatorTest.php
+++ /dev/null
@@ -1,328 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-use API2 as API;
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class CreatorSyncTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-		API::groupClear(self::$config['ownedPublicGroupID']);
-		self::$sessionID = Sync::login();
-	}
-	
-	
-	public function tearDown() {
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-	}
-	
-	
-	public function testCreatorItemChange() {
-		$key = 'AAAAAAAA';
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key . '">'
-			. '<creator key="BBBBBBBB" creatorType="author" index="0">'
-			. '<creator libraryID="' . self::$config['libraryID'] . '" '
-			. 'key="BBBBBBBB" dateAdded="2009-03-07 04:53:20" dateModified="2009-03-07 04:54:09">'
-			. '<firstName>First</firstName>'
-			. '<lastName>Last</lastName>'
-			. '<fieldMode>0</fieldMode>'
-			. '</creator></creator></item></items></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item version via API and check creatorSummary
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("Last", $creatorSummary);
-		$data = API::parseDataFromAtomEntry($xml);
-		$version = $data['version'];
-		
-		// Get item via sync
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$this->assertEquals(1, sizeOf($xml->updated->items->item));
-		
-		//
-		// Modify creator
-		//
-		$data = '<data version="9">'
-			. '<creators><creator libraryID="' . self::$config['libraryID'] . '" '
-			. 'key="BBBBBBBB" dateAdded="2009-03-07 04:53:20" dateModified="2009-03-07 04:54:09">'
-			. '<name>First Last</name>'
-			. '<fieldMode>1</fieldMode>'
-			. '</creator></creators></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("First Last", $creatorSummary);
-		$this->assertTrue(isset($json->creators[0]->name));
-		$this->assertEquals("First Last", $json->creators[0]->name);
-		$this->assertEquals($version + 1, $data['version']);
-		$version = $data['version'];
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		//
-		// Modify creator, and include unmodified item
-		//
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key . '">'
-			. '<creator key="BBBBBBBB" creatorType="author" index="0"/>'
-			. '</item></items>'
-			. '<creators><creator libraryID="' . self::$config['libraryID'] . '" '
-			. 'key="BBBBBBBB" dateAdded="2009-03-07 04:53:20" dateModified="2009-03-07 04:54:09">'
-			. '<firstName>Foo</firstName>'
-			. '<lastName>Bar</lastName>'
-			. '<fieldMode>0</fieldMode>'
-			. '</creator></creators></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("Bar", $creatorSummary);
-		$this->assertTrue(isset($json->creators[0]->firstName));
-		$this->assertEquals("Foo", $json->creators[0]->firstName);
-		$this->assertTrue(isset($json->creators[0]->lastName));
-		$this->assertEquals("Bar", $json->creators[0]->lastName);
-		$this->assertEquals($version + 1, $data['version']);
-	}
-	
-	
-	public function testCreatorItemChangeViaAPI() {
-		$key = 'AAAAAAAA';
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key . '">'
-			. '<creator key="BBBBBBBB" creatorType="author" index="0">'
-			. '<creator libraryID="' . self::$config['libraryID'] . '" '
-			. 'key="BBBBBBBB" dateAdded="2009-03-07 04:53:20" dateModified="2009-03-07 04:54:09">'
-			. '<firstName>First</firstName>'
-			. '<lastName>Last</lastName>'
-			. '<fieldMode>0</fieldMode>'
-			. '</creator></creator></item></items></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item version via API and check creatorSummary
-		API::useAPIVersion(1);
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("Last", $creatorSummary);
-		$data = API::parseDataFromAtomEntry($xml);
-		$etag = (string) array_get_first($xml->xpath('//atom:entry/atom:content/@zapi:etag'));
-		$this->assertNotEquals("", $etag);
-		
-		// Modify creator
-		$json = json_decode($data['content'], true);
-		$json['creators'][0] = array(
-			"name" => "First Last",
-			"creatorType" => "author"
-		);
-		
-		// Modify via API
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'],
-			json_encode($json),
-			array("If-Match: $etag")
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("First Last", $creatorSummary);
-		$this->assertTrue(isset($json->creators[0]->name));
-		$this->assertEquals("First Last", $json->creators[0]->name);
-		$newETag = (string) array_get_first($xml->xpath('//atom:entry/zapi:etag'));
-		$this->assertNotEquals($etag, $newETag);
-		
-		// Get item again via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$creatorSummary = (string) array_get_first($xml->xpath('//atom:entry/zapi:creatorSummary'));
-		$this->assertEquals("First Last", $creatorSummary);
-		$this->assertTrue(isset($json->creators[0]->name));
-		$this->assertEquals("First Last", $json->creators[0]->name);
-		$newETag = (string) array_get_first($xml->xpath('//atom:entry/zapi:etag'));
-		$this->assertNotEquals($etag, $newETag);
-	}
-	
-	
-	public function testEmptyCreator() {
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create creator via sync
-		$data = '<data version="9"><creators>'
-			. '<creator libraryID="' . self::$config['libraryID'] . '" '
-			. 'key="AAAAAAAA" dateAdded="2013-12-01 03:53:20" dateModified="2013-12-01 03:54:09">'
-			. '<name></name>'
-			. '<fieldMode>1</fieldMode>'
-			. '</creator>'
-			. '<creator libraryID="' . self::$config['libraryID'] . '" '
-			. 'key="BBBBBBBB" dateAdded="2013-12-01 04:53:20" dateModified="2013-12-01 04:54:09">'
-			. '<name>' . chr(0xEF) . chr(0xBB) . chr(0xBF) . '</name>' // \uFEFF
-			. '<fieldMode>1</fieldMode>'
-			. '</creator>'
-			. '</creators></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Creators should have been skipped
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$this->assertEquals(0, sizeOf($xml->updated->creators->creator));
-		
-		// Create creator with valid name
-		$data = '<data version="9"><creators>'
-			. '<creator libraryID="' . self::$config['libraryID'] . '" '
-			. 'key="AAAAAAAA" dateAdded="2013-12-01 03:53:20" dateModified="2013-12-01 03:54:09">'
-			. '<name>Test</name>'
-			. '<fieldMode>1</fieldMode>'
-			. '</creator>'
-			. '</creators></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$this->assertEquals(1, sizeOf($xml->updated->creators->creator));
-		
-		// Update with empty
-		$data = '<data version="9"><creators>'
-			. '<creator libraryID="' . self::$config['libraryID'] . '" '
-			. 'key="AAAAAAAA" dateAdded="2013-12-01 03:53:20" dateModified="2013-12-01 03:54:09">'
-			. '<name>' . chr(0xEF) . chr(0xBB) . chr(0xBF) . '</name>'
-			. '<fieldMode>1</fieldMode>'
-			. '</creator>'
-			. '</creators></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$this->assertEquals(1, sizeOf($xml->updated->creators->creator));
-		// Not ideal, but for now the updated creator should just be ignored
-		$this->assertEquals("Test", (string) $xml->updated->creators->creator->name);
-	}
-	
-	
-	public function testEmptyLinkedCreator() {
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create creator via sync
-		$data = '<data version="9"><creators>'
-			. '<creator libraryID="' . self::$config['libraryID'] . '" '
-			. 'key="AAAAAAAA" dateAdded="2013-12-01 03:53:20" dateModified="2013-12-01 03:54:09">'
-			. '<name>' . chr(0x7f) . '</name>'
-			. '<fieldMode>1</fieldMode>'
-			. '</creator>'
-			. '</creators>'
-			. '<items><item libraryID="' . self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2013-12-01 03:53:20" dateModified="2013-12-01 03:53:20" key="BBBBBBBB">'
-			. '<creator libraryID="' . self::$config['libraryID'] . '" '
-			. 'key="AAAAAAAA" creatorType="editor" index="0"/>'
-			. '</item></items></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Creators should have been skipped
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$this->assertEquals(0, sizeOf($xml->updated->creators->creator));
-	}
-}
diff --git a/tests/remote/tests/Sync/FullTextTest.php b/tests/remote/tests/Sync/FullTextTest.php
deleted file mode 100644
index d5a7dc8c..00000000
--- a/tests/remote/tests/Sync/FullTextTest.php
+++ /dev/null
@@ -1,374 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-use API2 as API;
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class SyncFullTextTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		self::$sessionID = Sync::login();
-	}
-	
-	public function tearDown() {
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-	}
-	
-	public function testFullTextSync() {
-		$xml = Sync::updated(self::$sessionID);
-		
-		$updateKey = (string) $xml['updateKey'];
-		$key = Zotero_Utilities::randomString(8, 'key', true);
-		$dateAdded = date('Y-m-d H:i:s', time() - 1);
-		$dateModified = date('Y-m-d H:i:s');
-		
-		$content = "This is some full-text content.";
-		$totalChars = 2500;
-		
-		$xmlstr = '<data version="9">'
-			. '<items>'
-			. '<item libraryID="' . self::$config['libraryID'] . '" '
-				. 'itemType="attachment" '
-				. 'dateAdded="' . $dateAdded . '" '
-				. 'dateModified="' . $dateModified . '" '
-				. 'key="' . $key . '"/>'
-			. '</items>'
-			. '<fulltexts>'
-			. '<fulltext libraryID="' . self::$config['libraryID'] . '" '
-				. 'key="' . $key . '" '
-				. 'indexedChars="' . strlen($content) . '" '
-				. 'totalChars="' . $totalChars . '" '
-				. 'indexedPages="0" '
-				. 'totalPages="0">'
-				. htmlspecialchars($content)
-			. '</fulltext>'
-			. '</fulltexts>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		$xml = Sync::updated(self::$sessionID, 1, false, false, ["ft" => 1]);
-		$lastSyncTimestamp = (int) $xml['timestamp'];
-		$this->assertEquals(1, $xml->updated[0]->fulltexts->count());
-		$this->assertEquals(1, $xml->updated[0]->fulltexts[0]->fulltext->count());
-		$this->assertEquals($content, (string) $xml->updated[0]->fulltexts[0]->fulltext[0]);
-		$this->assertEquals(strlen($content), (int) $xml->updated[0]->fulltexts[0]->fulltext[0]['indexedChars']);
-		$this->assertEquals($totalChars, (int) $xml->updated[0]->fulltexts[0]->fulltext[0]['totalChars']);
-		
-		$xml = Sync::updated(self::$sessionID, $lastSyncTimestamp + 1, false, false, ["ft" => 1]);
-		$this->assertEquals(0, $xml->updated[0]->fulltexts->count());
-		
-		$xml = Sync::updated(self::$sessionID, 1, false, false, ["ft" => 1]);
-		$this->assertEquals(1, $xml->updated[0]->fulltexts->count());
-	}
-	
-	public function testLargeFullTextSync() {
-		$xml = Sync::updated(self::$sessionID, 1, false, false, ["ft" => 1]);
-		$timestamp1 = (int) $xml['timestamp'];
-		$updateKey = (string) $xml['updateKey'];
-		
-		$key1 = Zotero_Utilities::randomString(8, 'key', true);
-		$key2 = Zotero_Utilities::randomString(8, 'key', true);
-		$key3 = Zotero_Utilities::randomString(8, 'key', true);
-		$key4 = Zotero_Utilities::randomString(8, 'key', true);
-		
-		$dateAdded = date( 'Y-m-d H:i:s', time() - 1);
-		$dateModified = date( 'Y-m-d H:i:s', time());
-		
-		$content1 = "This is test content";
-		$content2 = "This is more test content";
-		
-		$maxChars = 1000000;
-		$str = "abcdf ghijklm ";
-		$content3 = str_repeat("abcdf ghijklm ", ceil($maxChars / strlen($str)) + 1);
-		
-		$content4 = "This is even more test content";
-		
-		$xmlstr = '<data version="9">'
-			. '<items>'
-			. '<item libraryID="' . self::$config['libraryID'] . '" '
-				. 'itemType="attachment" '
-				. 'dateAdded="' . $dateAdded . '" '
-				. 'dateModified="' . $dateModified . '" '
-				. 'key="' . $key1 . '"/>'
-			. '<item libraryID="' . self::$config['libraryID'] . '" '
-				. 'itemType="attachment" '
-				. 'dateAdded="' . $dateAdded . '" '
-				. 'dateModified="' . $dateModified . '" '
-				. 'key="' . $key2 . '"/>'
-			. '</items>'
-			. '<fulltexts>'
-			. '<fulltext libraryID="' . self::$config['libraryID'] . '" '
-				. 'key="' . $key1 . '" '
-				. 'indexedChars="' . strlen($content1) . '" '
-				. 'totalChars="200000" '
-				. 'indexedPages="0" '
-				. 'totalPages="0">'
-				. htmlspecialchars($content1)
-			. '</fulltext>'
-			. '<fulltext libraryID="' . self::$config['libraryID'] . '" '
-				. 'key="' . $key2 . '" '
-				. 'indexedChars="' . strlen($content2) . '" '
-				. 'totalChars="200000" '
-				. 'indexedPages="0" '
-				. 'totalPages="0">'
-				. htmlspecialchars($content2)
-			. '</fulltext>'
-			. '</fulltexts>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this);
-		$timestamp2 = (int) $xml['timestamp'];
-		
-		$xml = Sync::updated(self::$sessionID, $timestamp2, false, false, ["ft" => 1]);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Wait until the timestamp advances
-		do {
-			$xml = Sync::updated(self::$sessionID, $timestamp2, false, false, ["ft" => 1]);
-			usleep(500);
-		}
-		while ((int) $xml['timestamp'] <= ($timestamp2 + 2));
-		
-		$xmlstr = '<data version="9">'
-			. '<items>'
-			. '<item libraryID="' . self::$config['libraryID'] . '" '
-				. 'itemType="attachment" '
-				. 'dateAdded="' . $dateAdded . '" '
-				. 'dateModified="' . $dateModified . '" '
-				. 'key="' . $key3 . '"/>'
-			. '<item libraryID="' . self::$config['libraryID'] . '" '
-				. 'itemType="attachment" '
-				. 'dateAdded="' . $dateAdded . '" '
-				. 'dateModified="' . $dateModified . '" '
-				. 'key="' . $key4 . '"/>'
-			. '</items>'
-			. '<fulltexts>'
-			. '<fulltext libraryID="' . self::$config['libraryID'] . '" '
-				. 'key="' . $key3 . '" '
-				. 'indexedChars="' . strlen($content3) . '" '
-				. 'totalChars="200000" '
-				. 'indexedPages="0" '
-				. 'totalPages="0">'
-				. htmlspecialchars($content3)
-			. '</fulltext>'
-			. '<fulltext libraryID="' . self::$config['libraryID'] . '" '
-				. 'key="' . $key4 . '" '
-				. 'indexedChars="' . strlen($content4) . '" '
-				. 'totalChars="200000" '
-				. 'indexedPages="0" '
-				. 'totalPages="0">'
-				. htmlspecialchars($content4)
-			. '</fulltext>'
-			. '</fulltexts>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this);
-		$timestamp3 = (int) $xml['timestamp'];
-		
-		// Get all results
-		$xml = Sync::updated(self::$sessionID, 1, false, false, ["ft" => 1]);
-		
-		$this->assertEquals(1, $xml->updated[0]->fulltexts->count());
-		$this->assertEquals(4, $xml->updated[0]->fulltexts[0]->fulltext->count());
-		
-		$resultContent1 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key1']"));
-		$resultContent2 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key2']"));
-		$resultContent3 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key3']"));
-		$resultContent4 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key4']"));
-		
-		if ($resultContent3 === "") {
-			$this->assertEquals($content1, $resultContent1);
-			$this->assertEquals($content2, $resultContent2);
-			$this->assertEquals($content4, $resultContent4);
-		}
-		else {
-			$this->assertEquals("", $resultContent1);
-			$this->assertEquals("", $resultContent2);
-			$this->assertEquals($content3, $resultContent3);
-			$this->assertEquals("", $resultContent4);
-		}
-		
-		// Request past last content
-		$xml = Sync::updated(self::$sessionID, $timestamp3, false, false, ["ft" => 1]);
-		$this->assertEquals(0, $xml->updated[0]->fulltexts->count());
-		
-		// Request for explicit keys
-		$params = ["ft" => 1];
-		$params["ftkeys"][self::$config['libraryID']] = [$key1, $key2, $key3, $key4];
-		$xml = Sync::updated(self::$sessionID, $timestamp3, false, false, $params);
-		
-		$this->assertEquals(1, $xml->updated[0]->fulltexts->count());
-		$this->assertEquals(4, $xml->updated[0]->fulltexts[0]->fulltext->count());
-		
-		$resultContent1 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key1']"));
-		$resultContent2 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key2']"));
-		$resultContent3 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key3']"));
-		$resultContent4 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key4']"));
-		
-		if ($resultContent3 === "") {
-			$this->assertEquals($content1, $resultContent1);
-			$this->assertEquals($content2, $resultContent2);
-			$this->assertEquals($content4, $resultContent4);
-		}
-		else {
-			$this->assertEquals("", $resultContent1);
-			$this->assertEquals("", $resultContent2);
-			$this->assertEquals($content3, $resultContent3);
-			$this->assertEquals("", $resultContent4);
-		}
-		
-		// Request for combo of time and keys
-		$params = ["ft" => 1];
-		$params["ftkeys"][self::$config['libraryID']] = [$key2];
-		$xml = Sync::updated(self::$sessionID, $timestamp2, false, false, $params);
-		
-		$this->assertEquals(1, $xml->updated[0]->fulltexts->count());
-		$this->assertEquals(3, $xml->updated[0]->fulltexts[0]->fulltext->count());
-		
-		$resultContent2 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key2']"));
-		$resultContent3 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key3']"));
-		$resultContent4 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key4']"));
-		
-		if ($resultContent3 === "") {
-			$this->assertEquals($content2, $resultContent2);
-			$this->assertEquals($content4, $resultContent4);
-		}
-		else {
-			$this->assertEquals("", $resultContent2);
-			$this->assertEquals($content3, $resultContent3);
-			$this->assertEquals("", $resultContent4);
-		}
-		
-		// Request past last content, again
-		$xml = Sync::updated(self::$sessionID, $timestamp3, false, false, ["ft" => 1]);
-		$this->assertEquals(0, $xml->updated[0]->fulltexts->count());
-		
-		// Request for single long content
-		$params = ["ft" => 1];
-		$params["ftkeys"][self::$config['libraryID']] = [$key3];
-		$xml = Sync::updated(self::$sessionID, $timestamp3, false, false, $params);
-		
-		$this->assertEquals(1, $xml->updated[0]->fulltexts->count());
-		$this->assertEquals(1, $xml->updated[0]->fulltexts[0]->fulltext->count());
-		
-		$resultContent3 = (string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key3']"));
-		$this->assertEquals($content3, $resultContent3);
-		
-		// Request for all items by upgrade flag
-		$params = [
-			"ft" => 1,
-			"ftkeys" => "all"
-		];
-		$xml = Sync::updated(self::$sessionID, $timestamp3, false, false, $params);
-		$this->assertEquals(1, $xml->updated[0]->fulltexts->count());
-		$this->assertEquals(4, $xml->updated[0]->fulltexts[0]->fulltext->count());
-		
-		// Request for empty items with FT disabled
-		$params = ["ft" => 0];
-		$xml = Sync::updated(self::$sessionID, 1, false, false, $params);
-		$this->assertEquals(1, $xml->updated[0]->fulltexts->count());
-		$this->assertEquals(4, $xml->updated[0]->fulltexts[0]->fulltext->count());
-		$this->assertEmpty((string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key1']")));
-		$this->assertEmpty((string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key2']")));
-		$this->assertEmpty((string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key3']")));
-		$this->assertEmpty((string) array_get_first($xml->updated[0]->fulltexts[0]->xpath("//fulltext[@key='$key4']")));
-	}
-	
-	
-	public function testFullTextNoAccess() {
-		API::groupClear(self::$config['ownedPrivateGroupID2']);
-		
-		// Add item to group as user 2
-		$user2SessionID = Sync::login([
-			'username' => self::$config['username2'],
-			'password' => self::$config['password2']
-		]);
-		$xml = Sync::updated($user2SessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$key = Zotero_Utilities::randomString(8, 'key', true);
-		$dateAdded = date('Y-m-d H:i:s', time() - 1);
-		$dateModified = date('Y-m-d H:i:s');
-		$xmlstr = '<data version="9">'
-			. '<items>'
-			. '<item libraryID="' . self::$config['ownedPrivateGroupLibraryID2'] . '" '
-				. 'itemType="attachment" '
-				. 'dateAdded="' . $dateAdded . '" '
-				. 'dateModified="' . $dateModified . '" '
-				. 'key="' . $key . '"/>'
-			. '</items>'
-			. '</data>';
-		$response = Sync::upload($user2SessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload($user2SessionID, $response, $this);
-		
-		// Make sure item exists
-		$xml = Sync::updated($user2SessionID, 1);
-		$this->assertEquals(1, $xml->updated[0]->items->count());
-		$this->assertEquals(1, $xml->updated[0]->items[0]->item->count());
-		
-		// Try to add full-text content as user 1
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$content = "This is some full-text content.";
-		$totalChars = 2500;
-		
-		$xmlstr = '<data version="9">'
-			. '<fulltexts>'
-			. '<fulltext libraryID="' . self::$config['ownedPrivateGroupLibraryID2'] . '" '
-				. 'key="' . $key . '" '
-				. 'indexedChars="' . strlen($content) . '" '
-				. 'totalChars="' . $totalChars . '" '
-				. 'indexedPages="0" '
-				. 'totalPages="0">'
-				. htmlspecialchars($content)
-			. '</fulltext>'
-			. '</fulltexts>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Retrieve it as user 2
-		$xml = Sync::updated($user2SessionID, 1, false, false, ["ft" => 1]);
-		$this->assertEquals(0, $xml->updated[0]->fulltexts->count());
-		
-		API::groupClear(self::$config['ownedPrivateGroupID2']);
-	}
-}
diff --git a/tests/remote/tests/Sync/ItemTest.php b/tests/remote/tests/Sync/ItemTest.php
deleted file mode 100644
index 6abe8650..00000000
--- a/tests/remote/tests/Sync/ItemTest.php
+++ /dev/null
@@ -1,174 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-use API2 as API;
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class SyncItemTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		self::$sessionID = Sync::login();
-	}
-	
-	
-	public function tearDown() {
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-	}
-	
-	
-	public function testCachedItem() {
-		$itemKey = Sync::createItem(
-			self::$sessionID, self::$config['libraryID'], "book", array(
-				"title" => "Test",
-				"numPages" => "204"
-			), $this
-		);
-		
-		Sync::updated(self::$sessionID);
-		
-		$xml = API::getItemXML($itemKey);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$json['creators'] = array(
-			array(
-				"firstName" => "First",
-				"lastName" => "Last",
-				"creatorType" => "author"
-			)
-		);
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$itemKey?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assertEquals(204, $response->getStatus());
-		
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals("Test", $xml->updated[0]->items[0]->item[0]->field[0]);
-		$this->assertEquals("204", $xml->updated[0]->items[0]->item[0]->field[1]);
-		$this->assertEquals(1, $xml->updated[0]->items[0]->item[0]->creator->count());
-		
-		// Fully cached response
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals("Test", $xml->updated[0]->items[0]->item[0]->field[0]);
-		$this->assertEquals("204", $xml->updated[0]->items[0]->item[0]->field[1]);
-		$this->assertEquals(1, $xml->updated[0]->items[0]->item[0]->creator->count());
-		
-		// Item-level caching
-		$xml = Sync::updated(self::$sessionID, 2);
-		$this->assertEquals("Test", $xml->updated[0]->items[0]->item[0]->field[0]);
-		$this->assertEquals("204", $xml->updated[0]->items[0]->item[0]->field[1]);
-		$this->assertEquals(1, $xml->updated[0]->items[0]->item[0]->creator->count());
-		
-		$xml = API::getItemXML($itemKey);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		
-		$json['title'] = "Test 2";
-		$json['creators'] = array(
-			array(
-				"firstName" => "First",
-				"lastName" => "Last",
-				"creatorType" => "author"
-			),
-			array(
-				"name" => "Test Name",
-				"creatorType" => "editor"
-			)
-		);
-		
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$itemKey?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals("Test 2", $xml->updated[0]->items[0]->item[0]->field[0]);
-		$this->assertEquals("204", $xml->updated[0]->items[0]->item[0]->field[1]);
-		$this->assertEquals(2, $xml->updated[0]->items[0]->item[0]->creator->count());
-		
-		$xml = Sync::updated(self::$sessionID, 3);
-		$this->assertEquals("Test 2", $xml->updated[0]->items[0]->item[0]->field[0]);
-		$this->assertEquals("204", $xml->updated[0]->items[0]->item[0]->field[1]);
-		$this->assertEquals(2, $xml->updated[0]->items[0]->item[0]->creator->count());
-	}
-	
-	
-	public function testComputerProgram() {
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$itemKey = 'AAAAAAAA';
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="computerProgram" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $itemKey . '">'
-			. '<field name="version">1.0</field>'
-			. '</item></items></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item version via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$itemKey?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$xml = API::getItemXML($itemKey);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content'], true);
-		$this->assertEquals('1.0', $json['version']);
-		
-		$json['version'] = '1.1';
-		$response = API::userPut(
-			self::$config['userID'],
-			"items/$itemKey?key=" . self::$config['apiKey'],
-			json_encode($json)
-		);
-		$this->assertEquals(204, $response->getStatus());
-		
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals('version', (string) $xml->updated[0]->items[0]->item[0]->field[0]['name']);
-	}
-}
diff --git a/tests/remote/tests/Sync/NoteTest.php b/tests/remote/tests/Sync/NoteTest.php
deleted file mode 100644
index a7a61d42..00000000
--- a/tests/remote/tests/Sync/NoteTest.php
+++ /dev/null
@@ -1,221 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-use API2 as API;
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class SyncNoteTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-		API::groupClear(self::$config['ownedPublicGroupID']);
-		self::$sessionID = Sync::login();
-		Sync::useZoteroVersion();
-	}
-	
-	
-	public function tearDown() {
-		Sync::useZoteroVersion();
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-	}
-	
-	
-	public function testNoteTooLong() {
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$content = str_repeat("1234567890", 25001);
-		
-		// Create too-long note via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="note" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="AAAAAAAA"><note>' . $content . '</note></item></items></data>';
-		
-		// Create too-long note with content within HTML tags
-		$content = "<p><!-- $content --></p>";
-		
-		//
-		// < 4.0.27
-		//
-		Sync::useZoteroVersion("4.0.26.4");
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("ERROR_PROCESSING_UPLOAD_DATA", $xml->error["code"]);
-		$this->assertRegExp('/^The note \'.+\' in your library is too long /', (string) $xml->error);
-		$this->assertRegExp('/ copy and paste \'AAAAAAAA\' into /', (string) $xml->error);
-		
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="note" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="AAAAAAAA"><note>' . htmlentities($content) . '</note></item></items></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("ERROR_PROCESSING_UPLOAD_DATA", $xml->error["code"]);
-		$this->assertRegExp('/^The note \'<p><!-- 12345678901234567890[0-9]+…\' in your library is too long /', (string) $xml->error);
-		$this->assertRegExp('/ copy and paste \'AAAAAAAA\' into /', (string) $xml->error);
-		
-		//
-		// >=4.0.27
-		//
-		Sync::useZoteroVersion();
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("NOTE_TOO_LONG", $xml->error["code"]);
-		$this->assertRegExp('/^The note \'.+\' in your library is too long /', (string) $xml->error);
-		$this->assertRegExp('/\/AAAAAAAA$/', (string) $xml->item);
-		
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="note" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="AAAAAAAA"><note>' . htmlentities($content) . '</note></item></items></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("NOTE_TOO_LONG", $xml->error["code"]);
-		$this->assertRegExp('/^The note \'<p><!-- 12345678901234567890[0-9]+…\' in your library is too long /', (string) $xml->error);
-		$this->assertRegExp('/\/AAAAAAAA$/', (string) $xml->item);
-		
-		
-		// Create note under the length limit
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$content = str_repeat("1234567890", 24999);
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="note" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="AAAAAAAA"><note>' . $content . '</note></item></items></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-	}
-	
-	
-	public function testNoteTooLongWithA0() {
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$content = file_get_contents('data/bad_string.xml') . str_repeat("1234567890", 25001);
-		
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="note" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="AAAAAAAA"><note>' . $content . '</note></item></items></data>';
-		
-		//
-		// < 4.0.27
-		//
-		Sync::useZoteroVersion("4.0.26.4");
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("ERROR_PROCESSING_UPLOAD_DATA", $xml->error["code"]);
-		$this->assertRegExp('/^The note \'.+\' in your library is too long /', (string) $xml->error);
-		$this->assertRegExp('/ copy and paste \'AAAAAAAA\' into /', (string) $xml->error);
-		
-		//
-		// >=4.0.27
-		//
-		Sync::useZoteroVersion();
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("NOTE_TOO_LONG", $xml->error["code"]);
-		$this->assertRegExp('/^The note \'.+\' in your library is too long /', (string) $xml->error);
-		$this->assertRegExp('/\/AAAAAAAA$/', (string) $xml->item);
-	}
-	
-	
-	public function testNoteWayTooLong() {
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$content = str_repeat("1", 10000000);
-		
-		// Create too-long note via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="note" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="AAAAAAAA"><note>' . $content . '</note></item></items></data>';
-		
-		//
-		// < 4.0.27
-		//
-		Sync::useZoteroVersion("4.0.26.4");
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("ERROR_PROCESSING_UPLOAD_DATA", $xml->error["code"]);
-		$this->assertRegExp('/^The note \'.+\' in your library is too long /', (string) $xml->error);
-		$this->assertRegExp('/ copy and paste \'AAAAAAAA\' into /', (string) $xml->error);
-		
-		//
-		// >=4.0.27
-		//
-		Sync::useZoteroVersion();
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("NOTE_TOO_LONG", $xml->error["code"]);
-		$this->assertRegExp('/^The note \'.+\' in your library is too long /', (string) $xml->error);
-		$this->assertRegExp('/\/AAAAAAAA$/', (string) $xml->item);
-	}
-}
-?>
\ No newline at end of file
diff --git a/tests/remote/tests/Sync/ObjectTest.php b/tests/remote/tests/Sync/ObjectTest.php
deleted file mode 100644
index fc3c6f5a..00000000
--- a/tests/remote/tests/Sync/ObjectTest.php
+++ /dev/null
@@ -1,214 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-use API2 as API;
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class SyncObjectTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-		API::groupClear(self::$config['ownedPublicGroupID']);
-		self::$sessionID = Sync::login();
-	}
-	
-	
-	public function tearDown() {
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-	}
-	
-	
-	/**
-	 * Create and delete an object via sync and check with /deleted?newer=0
-	 */
-	public function testDeleteAndDeleted() {
-		// TODO
-		//$this->_testDeleteAndDeleted('collection');
-		$this->_testDeleteAndDeleted('item');
-		//$this->_testDeleteAndDeleted('search');
-		$this->_testDeleteAndDeleted('setting');
-	}
-	
-	
-	private function _testDeleteAndDeleted($objectType) {
-		API::userClear(self::$config['userID']);
-		
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$lastSyncTimestamp = (int) $xml['timestamp'];
-		
-		// Create via sync
-		switch ($objectType) {
-		case 'item':
-			$keys[] = Sync::createItem(
-				self::$sessionID, self::$config['libraryID'], "book", false, $this
-			);
-			break;
-		
-		case 'setting':
-			$settingKey = "tagColors";
-			$response = API::userPut(
-				self::$config['userID'],
-				"settings/$settingKey?key=" . self::$config['apiKey'],
-				json_encode(array(
-					"value" => array(
-						array(
-							"name" => "_READ",
-							"color" => "#990000"
-						)
-					)
-				)),
-				array(
-					"Content-Type: application/json",
-					"If-Unmodified-Since-Version: 0"
-				)
-			);
-			$this->assertEquals(204, $response->getStatus());
-			$keys[] = $settingKey;
-			break;
-		}
-		
-		// Check via API
-		foreach ($keys as $key) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"$objectTypePlural/$key?key=" . self::$config['apiKey']
-			);
-			$this->assertEquals(200, $response->getStatus());
-			$version = $response->getHeader("Last-Modified-Version");
-			$this->assertNotNull($version);
-		}
-		
-		// Get empty deleted via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey'] . "&newer=$version"
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$json = json_decode($response->getBody(), true);
-		$this->assertEmpty($json[$objectTypePlural]);
-		
-		// Get empty deleted via API with newertime
-		$response = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey'] . "&newertime=$lastSyncTimestamp"
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$json = json_decode($response->getBody(), true);
-		$this->assertEmpty($json[$objectTypePlural]);
-		
-		// Delete via sync
-		foreach ($keys as $key) {
-			switch ($objectType) {
-			case 'item':
-				Sync::deleteItem(self::$sessionID, self::$config['libraryID'], $key, $this);
-				break;
-			
-			case 'setting':
-				// Delete via sync
-				$xml = Sync::updated(self::$sessionID);
-				$updateKey = (string) $xml['updateKey'];
-				$xmlstr = '<data version="9">'
-					. '<deleted>'
-					. '<settings>'
-					. '<setting libraryID="' . self::$config['libraryID']
-						. '" key="' . $key . '"/>'
-					. '</settings>'
-					. '</deleted>'
-					. '</data>';
-				$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-				Sync::waitForUpload(self::$sessionID, $response, $this);
-				break;
-			}
-		}
-		
-		// Check 404 via API
-		foreach ($keys as $key) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"$objectTypePlural/$key?key=" . self::$config['apiKey']
-			);
-			$this->assertEquals(404, $response->getStatus());
-		}
-		
-		// Get deleted via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey'] . "&newer=$version"
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$json = json_decode($response->getBody(), true);
-		$this->assertArrayHasKey($objectTypePlural, $json);
-		$this->assertCount(sizeOf($keys), $json[$objectTypePlural]);
-		foreach ($keys as $key) {
-			$this->assertContains($key, $json[$objectTypePlural]);
-		}
-		
-		// Get deleted via API with newertime
-		$response = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey']
-				. "&newertime=$lastSyncTimestamp"
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$json = json_decode($response->getBody(), true);
-		$this->assertArrayHasKey($objectTypePlural, $json);
-		$this->assertCount(sizeOf($keys), $json[$objectTypePlural]);
-		foreach ($keys as $key) {
-			$this->assertContains($key, $json[$objectTypePlural]);
-		}
-		
-		// Should be empty with later newertime
-		$xml = Sync::updated(self::$sessionID);
-		$lastSyncTimestamp = (int) $xml['timestamp'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"deleted?key=" . self::$config['apiKey']
-				// server uses NOW() + 1
-				. "&newertime=" . ($lastSyncTimestamp + 2)
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$json = json_decode($response->getBody(), true);
-		$this->assertEmpty($json[$objectTypePlural]);
-	}
-}
diff --git a/tests/remote/tests/Sync/PermissionsTest.php b/tests/remote/tests/Sync/PermissionsTest.php
deleted file mode 100644
index 2d432a4a..00000000
--- a/tests/remote/tests/Sync/PermissionsTest.php
+++ /dev/null
@@ -1,156 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-use API2 as API;
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class SyncPermissionsTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	protected static $sessionID2;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-		API::groupClear(self::$config['ownedPrivateGroupID2']);
-		
-		self::$sessionID = Sync::login();
-		self::$sessionID2 = Sync::login(
-			array(
-				'username' => self::$config['username2'],
-				'password' => self::$config['password2']
-			)
-		);
-	}
-	
-	
-	public function tearDown() {
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-		Sync::logout(self::$sessionID2);
-		self::$sessionID2 = null;
-	}
-	
-	
-	public function testAddItemLibraryAccessDenied() {
-		$xml = Sync::updated(self::$sessionID2);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create item
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="note" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="AAAAAAAA"><note>Test</note></item></items></data>';
-		$response = Sync::upload(self::$sessionID2, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID2, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("LIBRARY_ACCESS_DENIED", $xml->error["code"]);
-	}
-	
-	
-	public function testDeleteItemLibraryAccessDenied() {
-		// Create item
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="note" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="AAAAAAAA"><note>Test</note></item></items></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Delete item without permissions
-		$xml = Sync::updated(self::$sessionID2);
-		$updateKey = (string) $xml['updateKey'];
-		$data = '<data version="9"><deleted><items><item libraryID="'
-			. self::$config['libraryID'] . '" key="AAAAAAAA"/>'
-			. "</items></deleted></data>";
-		$response = Sync::upload(self::$sessionID2, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID2, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("LIBRARY_ACCESS_DENIED", $xml->error["code"]);
-	}
-	
-	
-	public function testGroupAddItemLibraryAccessDenied() {
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create item
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['ownedPrivateGroupLibraryID2'] . '" itemType="note" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="AAAAAAAA"><note>Test</note></item></items></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("LIBRARY_ACCESS_DENIED", $xml->error["code"]);
-	}
-	
-	
-	public function testGroupDeleteItemLibraryAccessDenied() {
-		// Create item
-		$xml = Sync::updated(self::$sessionID2);
-		$updateKey = (string) $xml['updateKey'];
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['ownedPrivateGroupLibraryID2'] . '" itemType="note" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="AAAAAAAA"><note>Test</note></item></items></data>';
-		$response = Sync::upload(self::$sessionID2, $updateKey, $data, true);
-		Sync::waitForUpload(self::$sessionID2, $response, $this);
-		
-		// Delete item without permissions
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$data = '<data version="9"><deleted><items><item libraryID="'
-			. self::$config['ownedPrivateGroupLibraryID2'] . '" key="AAAAAAAA"/>'
-			. "</items></deleted></data>";
-		$response = Sync::upload(self::$sessionID, $updateKey, $data, true);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this, true);
-		
-		$this->assertTrue(isset($xml->error));
-		$this->assertEquals("LIBRARY_ACCESS_DENIED", $xml->error["code"]);
-	}
-}
-
diff --git a/tests/remote/tests/Sync/RelationTest.php b/tests/remote/tests/Sync/RelationTest.php
deleted file mode 100644
index cbeb3449..00000000
--- a/tests/remote/tests/Sync/RelationTest.php
+++ /dev/null
@@ -1,579 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-use API2 as API;
-require_once 'include/bootstrap.inc.php';
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class SyncRelationTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-		API::groupClear(self::$config['ownedPublicGroupID']);
-		self::$sessionID = Sync::login();
-	}
-	
-	
-	public function tearDown() {
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-	}
-	
-	
-	public function testModifyRelationsViaSync() {
-		$items = array();
-		$items[] = array(
-			"key" => API::createItem("book", false, null, 'key'),
-			"relations" => array(
-				array("owl:sameAs", "http://zotero.org/groups/1/items/AAAAAAAA")
-			)
-		);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = $xml['updateKey'];
-		$lastSyncTimestamp = $xml['timestamp'];
-		
-		$xmlstr = '<data version="9">'
-			. '<relations>';
-		foreach ($items as $item) {
-			$subject = 'http://zotero.org/users/'
-				. self::$config['userID'] . '/items/' . $item['key'];
-			foreach ($item['relations'] as $rel) {
-				$xmlstr .= '<relation libraryID="' . self::$config['libraryID'] . '">'
-				. "<subject>$subject</subject>"
-				. "<predicate>{$rel[0]}</predicate>"
-				. "<object>{$rel[1]}</object>"
-				. '</relation>';
-			}
-		}
-		$xmlstr .= '</relations>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Check via API
-		foreach ($items as $item) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items/{$item['key']}?key=" . self::$config['apiKey'] . "&content=json"
-			);
-			$content = API::getContentFromResponse($response);
-			$json = json_decode($content, true);
-			$uniquePredicates = array_unique(array_map(function ($x) { return $x[0]; }, $item['relations']));
-			$this->assertCount(sizeOf($uniquePredicates), $json['relations']);
-			foreach ($item['relations'] as $rel) {
-				$this->assertArrayHasKey($rel[0], $json['relations']);
-				$this->assertContains($rel[1], $json['relations'][$rel[0]]);
-			}
-		}
-		
-		$xml = Sync::updated(self::$sessionID);
-		
-		// Deleting item via API should log sync deletes for relations
-		$item = $items[0];
-		$subject = 'http://zotero.org/users/'
-				. self::$config['userID'] . '/items/' . $item['key'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$item['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$libraryVersion = $response->getHeader('Last-Modified-Version');
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"items/{$item['key']}?key=" . self::$config['apiKey'],
-			array("If-Unmodified-Since-Version: $libraryVersion")
-		);
-		$this->assertEquals(204, $response->getStatus());
-		
-		$xml = Sync::updated(self::$sessionID);
-		
-		$this->assertEquals(0, $xml->updated[0]->relations->count());
-		$this->assertEquals(1, $xml->updated[0]->deleted[0]->items[0]->item->count());
-		$this->assertEquals(sizeOf($item['relations']), $xml->updated[0]->deleted[0]->relations[0]->relation->count());
-		foreach ($item['relations'] as $rel) {
-			$relKey = md5($subject . " " . $rel[0] . " " . $rel[1]);
-			$this->assertEquals(1, sizeOf($xml->updated[0]->deleted[0]->relations[0]->xpath("relation[@key='$relKey']")));
-		}
-	}
-	
-	
-	public function testModifyRelationsArrayViaSync() {
-		$items = array();
-		$items[] = array(
-			"key" => API::createItem("book", false, null, 'key'),
-			"relations" => array(
-				array("owl:sameAs", "http://zotero.org/groups/1/items/AAAAAAAA"),
-				array("owl:sameAs", "http://zotero.org/groups/1/items/BBBBBBBB")
-			)
-		);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = $xml['updateKey'];
-		$lastSyncTimestamp = $xml['timestamp'];
-		
-		$xmlstr = '<data version="9">'
-			. '<relations>';
-		foreach ($items as $item) {
-			$subject = 'http://zotero.org/users/'
-				. self::$config['userID'] . '/items/' . $item['key'];
-			foreach ($item['relations'] as $rel) {
-				$xmlstr .= '<relation libraryID="' . self::$config['libraryID'] . '">'
-				. "<subject>$subject</subject>"
-				. "<predicate>{$rel[0]}</predicate>"
-				. "<object>{$rel[1]}</object>"
-				. '</relation>';
-			}
-		}
-		$xmlstr .= '</relations>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Check via API
-		foreach ($items as $item) {
-			$response = API::userGet(
-				self::$config['userID'],
-				"items/{$item['key']}?key=" . self::$config['apiKey'] . "&content=json"
-			);
-			$content = API::getContentFromResponse($response);
-			$json = json_decode($content, true);
-			$uniquePredicates = array_unique(array_map(function ($x) { return $x[0]; }, $item['relations']));
-			$this->assertCount(sizeOf($uniquePredicates), $json['relations']);
-			foreach ($item['relations'] as $rel) {
-				$this->assertArrayHasKey($rel[0], $json['relations']);
-				$this->assertContains($rel[1], $json['relations'][$rel[0]]);
-			}
-		}
-		
-		$xml = Sync::updated(self::$sessionID);
-		
-		// Deleting item via API should log sync deletes for relations
-		$item = $items[0];
-		$subject = 'http://zotero.org/users/'
-				. self::$config['userID'] . '/items/' . $item['key'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$item['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$libraryVersion = $response->getHeader('Last-Modified-Version');
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"items/{$item['key']}?key=" . self::$config['apiKey'],
-			array("If-Unmodified-Since-Version: $libraryVersion")
-		);
-		$this->assertEquals(204, $response->getStatus());
-		
-		$xml = Sync::updated(self::$sessionID);
-		
-		$this->assertEquals(0, $xml->updated[0]->relations->count());
-		$this->assertEquals(1, $xml->updated[0]->deleted[0]->items[0]->item->count());
-		$this->assertEquals(sizeOf($item['relations']), $xml->updated[0]->deleted[0]->relations[0]->relation->count());
-		foreach ($item['relations'] as $rel) {
-			$relKey = md5($subject . " " . $rel[0] . " " . $rel[1]);
-			$this->assertEquals(1, sizeOf($xml->updated[0]->deleted[0]->relations[0]->xpath("relation[@key='$relKey']")));
-		}
-	}
-	
-	
-	public function testReverseSameAs() {
-		$items = array();
-		$item = [
-			"key" => API::createItem("book", false, null, 'key'),
-			"relations" => [
-				["owl:sameAs", "http://zotero.org/groups/1/items/AAAAAAAA"],
-			]
-		];
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = $xml['updateKey'];
-		$lastSyncTimestamp = $xml['timestamp'];
-		
-		$xmlstr = '<data version="9">'
-			. '<relations>';
-		$subject = 'http://zotero.org/users/'
-			. self::$config['userID'] . '/items/' . $item['key'];
-		// Insert backwards, as client does via classic sync
-		// if group item is dragged to personal library
-		foreach ($item['relations'] as $rel) {
-			$xmlstr .= '<relation libraryID="' . self::$config['libraryID'] . '">'
-			. "<subject>{$rel[1]}</subject>"
-			. "<predicate>{$rel[0]}</predicate>"
-			. "<object>$subject</object>"
-			. '</relation>';
-		}
-		$xmlstr .= '</relations>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Check via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$item['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$content = API::getContentFromResponse($response);
-		$json = json_decode($content, true);
-		$uniquePredicates = array_unique(array_map(function ($x) { return $x[0]; }, $item['relations']));
-		$this->assertCount(sizeOf($uniquePredicates), $json['relations']);
-		foreach ($item['relations'] as $rel) {
-			$this->assertArrayHasKey($rel[0], $json['relations']);
-			$this->assertContains($rel[1], $json['relations'][$rel[0]]);
-		}
-		
-		// PUT via API, which should be unchanged
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"items" => [
-					$json
-				]
-			])
-		);
-		$results = json_decode($response->getBody(), true);
-		$this->assertArrayHasKey('unchanged', $results);
-		$this->assertContains($item['key'], $results['unchanged']);
-		
-		// Add another owl:sameAs via API
-		if (is_string($json['relations']['owl:sameAs'])) {
-			$json['relations']['owl:sameAs'] = [$json['relations']['owl:sameAs']];
-		}
-		$newURI = "http://zotero.org/groups/1/items/BBBBBBBB";
-		$json['relations']['owl:sameAs'][] = $newURI;
-		$item['relations'][] = ['owl:sameAs', $newURI];
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"items" => [
-					$json
-				]
-			])
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$results = json_decode($response->getBody(), true);
-		$this->assertArrayHasKey('success', $results);
-		$this->assertContains($item['key'], $results['success']);
-		
-		// Check via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$item['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$content = API::getContentFromResponse($response);
-		$json = json_decode($content, true);
-		$uniquePredicates = array_unique(array_map(function ($x) { return $x[0]; }, $item['relations']));
-		$this->assertCount(sizeOf($uniquePredicates), $json['relations']);
-		foreach ($item['relations'] as $rel) {
-			$this->assertArrayHasKey($rel[0], $json['relations']);
-			$this->assertContains($rel[1], $json['relations'][$rel[0]]);
-		}
-		$this->assertArrayHasKey("owl:sameAs", $json['relations']);
-		$this->assertContains($newURI, $json['relations']['owl:sameAs']);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = $xml['updateKey'];
-		
-		// First URL should still be in reverse order
-		$this->assertEquals(2, sizeOf($xml->updated[0]->relations->xpath("//relations/relation")));
-		$subRel = $xml->updated[0]->relations->xpath("//relations/relation[subject/text() = '{$item['relations'][0][1]}']");
-		$objRel = $xml->updated[0]->relations->xpath("//relations/relation[object/text() = '$newURI']");
-		$this->assertEquals(1, sizeOf($subRel));
-		$this->assertEquals($subject, $subRel[0]->object);
-		$this->assertEquals(1, sizeOf($objRel));
-		$this->assertEquals($subject, $objRel[0]->subject);
-		
-		// Resave second relation via classic sync in reverse order
-		$xmlstr = '<data version="9"><relations>';
-		$xmlstr .= '<relation libraryID="' . self::$config['libraryID'] . '">'
-		. "<subject>$newURI</subject>"
-		. "<predicate>owl:sameAs</predicate>"
-		. "<object>$subject</object>"
-		. "</relation></relations></data>";
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals(2, sizeOf($xml->updated[0]->relations->xpath("//relations/relation")));
-		
-		// Delete reverse relation via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$item['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$content = API::getContentFromResponse($response);
-		$json = json_decode($content, true);
-		// Leave just the relation that's entered in normal order
-		$json['relations']['owl:sameAs'] = [$newURI];
-		$item['relations'] = ['owl:sameAs', $newURI];
-		$response = API::userPost(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey'],
-			json_encode([
-				"items" => [
-					$json
-				]
-			])
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$results = json_decode($response->getBody(), true);
-		$this->assertArrayHasKey('success', $results);
-		$this->assertContains($item['key'], $results['success']);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/{$item['key']}?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$content = API::getContentFromResponse($response);
-		$json = json_decode($content, true);
-		$this->assertArrayHasKey("owl:sameAs", $json['relations']);
-		$this->assertContains($newURI, $json['relations']['owl:sameAs']);
-		// Should only have one relation left
-		$this->assertEquals(1, sizeOf($json['relations']['owl:sameAs']));
-	}
-	
-	
-	public function testIsReplacedBy() {
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = $xml['updateKey'];
-		$lastSyncTimestamp = $xml['timestamp'];
-		
-		$key1 = 'AAAAAAAA';
-		$uri1 = "http://zotero.org/users/" . self::$config['userID'] . '/items/' . $key1;
-		$data = API::createItem("journalArticle", array(
-			"relations" => array(
-				"dc:replaces" => $uri1
-			)
-		), null, 'data');
-		$key2 = $data['key'];
-		$libraryVersion = $data['version'];
-		$uri2 = "http://zotero.org/users/" . self::$config['userID'] . '/items/' . $key2;
-		
-		$xml = Sync::updated(self::$sessionID);
-		
-		// For classic sync, dc:replaces should be swapped for dc:isReplacedBy
-		$this->assertEquals($uri1, (string) $xml->updated[0]->relations->relation[0]->subject);
-		$this->assertEquals("dc:isReplacedBy", (string) $xml->updated[0]->relations->relation[0]->predicate);
-		$this->assertEquals($uri2, (string) $xml->updated[0]->relations->relation[0]->object);
-		
-		$response = API::userDelete(
-			self::$config['userID'],
-			"items/$key2?key=" . self::$config['apiKey'],
-			array("If-Unmodified-Since-Version: $libraryVersion")
-		);
-		$this->assertEquals(204, $response->getStatus());
-		
-		$xml = Sync::updated(self::$sessionID);
-		
-		$this->assertEquals(1, $xml->updated[0]->relations->count());
-		$this->assertEquals(1, $xml->updated[0]->deleted[0]->items[0]->item->count());
-		$this->assertEquals(0, $xml->updated[0]->deleted[0]->items[0]->relations->count());
-	}
-	
-	
-	public function testRelatedItems() {
-		$itemKey1 = API::createItem("audioRecording", array(
-			"relations" => array(
-				'owl:sameAs' => 'http://zotero.org/groups/1/items/AAAAAAAA'
-			)
-		), null, 'key');
-		$itemURI1 = 'http://zotero.org/users/' . self::$config['userID'] . '/items/' . $itemKey1;
-		
-		$itemKey2 = API::createItem("interview", array(
-			"relations" => array(
-				'dc:relation' => $itemURI1
-			)
-		), null, 'key');
-		$itemURI2 = 'http://zotero.org/users/' . self::$config['userID'] . '/items/' . $itemKey2;
-		
-		$itemKey3 = API::createItem("book", null, null, 'key');
-		$itemURI3 = 'http://zotero.org/users/' . self::$config['userID'] . '/items/' . $itemKey3;
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Add related items via sync
-		$xml = Sync::updated(self::$sessionID);
-		
-		$updateKey = $xml['updateKey'];
-		$lastSyncTimestamp = $xml['timestamp'];
-		
-		$itemXML1 = array_get_first($xml->updated[0]->items[0]->xpath("item[@key='$itemKey1']"));
-		$itemXML2 = array_get_first($xml->updated[0]->items[0]->xpath("item[@key='$itemKey2']"));
-		$itemXML3 = array_get_first($xml->updated[0]->items[0]->xpath("item[@key='$itemKey3']"));
-		$itemXML1['libraryID'] = self::$config['libraryID'];
-		$itemXML2['libraryID'] = self::$config['libraryID'];
-		$itemXML3['libraryID'] = self::$config['libraryID'];
-		$itemXML1->related = $itemKey2 . ' ' . $itemKey3;
-		$itemXML2->related = $itemKey1;
-		$itemXML3->related = $itemKey1;
-		
-		$xmlstr = '<data version="9">'
-			. '<items>'
-			. $itemXML1->asXML()
-			. $itemXML2->asXML()
-			. $itemXML3->asXML()
-			. '</items>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Check via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey']
-				. "&content=json&newer=$libraryVersion"
-		);
-		$xml = API::getXMLFromResponse($response);
-		// Item 2 already had the relation and shouldn't have been updated
-		$this->assertEquals(2, (int) array_get_first($xml->xpath('/atom:feed/zapi:totalResults')));
-		$itemJSON1 = json_decode(array_get_first($xml->xpath("//atom:entry[atom:id='$itemURI1']"))->content, 1);
-		$itemJSON3 = json_decode(array_get_first($xml->xpath("//atom:entry[atom:id='$itemURI3']"))->content, 1);
-		$this->assertInternalType('array', $itemJSON1['relations']['dc:relation']);
-		$this->assertInternalType('string', $itemJSON3['relations']['dc:relation']);
-		$this->assertCount(2, $itemJSON1['relations']['dc:relation']);
-		$this->assertTrue(in_array($itemURI2, $itemJSON1['relations']['dc:relation']));
-		$this->assertTrue(in_array($itemURI3, $itemJSON1['relations']['dc:relation']));
-		$this->assertEquals($itemURI1, $itemJSON3['relations']['dc:relation']);
-	}
-	
-	
-	public function testCircularRelatedItems() {
-		$parentKey = API::createItem("book", false, null, 'key');
-		$noteKeys = [
-			API::createNoteItem("Note 1", $parentKey, null, 'key'),
-			API::createNoteItem("Note 2", $parentKey, null, 'key'),
-			API::createNoteItem("Note 3", $parentKey, null, 'key'),
-			API::createNoteItem("Note 4", $parentKey, null, 'key')
-		];
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = $xml['updateKey'];
-		
-		$note1XML = array_get_first($xml->updated[0]->items->xpath("//item[@key = '{$noteKeys[0]}']"));
-		$note2XML = array_get_first($xml->updated[0]->items->xpath("//item[@key = '{$noteKeys[1]}']"));
-		$note3XML = array_get_first($xml->updated[0]->items->xpath("//item[@key = '{$noteKeys[2]}']"));
-		$note4XML = array_get_first($xml->updated[0]->items->xpath("//item[@key = '{$noteKeys[3]}']"));
-		
-		$note1XML['libraryID'] = self::$config['libraryID'];
-		$note2XML['libraryID'] = self::$config['libraryID'];
-		$note3XML['libraryID'] = self::$config['libraryID'];
-		$note4XML['libraryID'] = self::$config['libraryID'];
-		
-		$note1XML->related = implode(' ', [
-			$parentKey,
-			(string) $note2XML['key'],
-			(string) $note3XML['key'],
-			(string) $note4XML['key']
-		]);
-		
-		$note2XML->related = implode(' ', [
-			$parentKey,
-			(string) $note1XML['key'],
-			(string) $note3XML['key'],
-			(string) $note4XML['key']
-		]);
-		
-		$note3XML->related = implode(' ', [
-			$parentKey,
-			(string) $note1XML['key'],
-			(string) $note2XML['key'],
-			(string) $note4XML['key']
-		]);
-		
-		$note4XML->related = implode(' ', [
-			$parentKey,
-			(string) $note1XML['key'],
-			(string) $note2XML['key'],
-			(string) $note3XML['key']
-		]);
-		
-		$xmlstr = '<data version="9">'
-			. '<items>'
-			. $note1XML->asXML()
-			. $note2XML->asXML()
-			. $note3XML->asXML()
-			. $note4XML->asXML()
-			. '</items>'
-			. '</data>';
-		
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		$xml = Sync::updated(self::$sessionID);
-		
-		$noteXML = array_get_first($xml->updated[0]->items->xpath("//item[@key = '{$noteKeys[0]}']"));
-		$keys = split(' ', $noteXML->related);
-		$this->assertCount(4, $keys);
-		$this->assertContains($parentKey, $keys);
-		$this->assertContains((string) $noteKeys[1], $keys);
-		$this->assertContains((string) $noteKeys[2], $keys);
-		$this->assertContains((string) $noteKeys[3], $keys);
-		
-		$noteXML = array_get_first($xml->updated[0]->items->xpath("//item[@key = '{$noteKeys[1]}']"));
-		$keys = split(' ', $noteXML->related);
-		$this->assertCount(4, $keys);
-		$this->assertContains($parentKey, $keys);
-		$this->assertContains((string) $noteKeys[0], $keys);
-		$this->assertContains((string) $noteKeys[2], $keys);
-		$this->assertContains((string) $noteKeys[3], $keys);
-		
-		$noteXML = array_get_first($xml->updated[0]->items->xpath("//item[@key = '{$noteKeys[2]}']"));
-		$keys = split(' ', $noteXML->related);
-		$this->assertCount(4, $keys);
-		$this->assertContains($parentKey, $keys);
-		$this->assertContains((string) $noteKeys[0], $keys);
-		$this->assertContains((string) $noteKeys[1], $keys);
-		$this->assertContains((string) $noteKeys[3], $keys);
-		
-		$noteXML = array_get_first($xml->updated[0]->items->xpath("//item[@key = '{$noteKeys[3]}']"));
-		$keys = split(' ', $noteXML->related);
-		$this->assertCount(4, $keys);
-		$this->assertContains($parentKey, $keys);
-		$this->assertContains((string) $noteKeys[0], $keys);
-		$this->assertContains((string) $noteKeys[1], $keys);
-		$this->assertContains((string) $noteKeys[2], $keys);
-	}
-}
diff --git a/tests/remote/tests/Sync/SettingsTest.php b/tests/remote/tests/Sync/SettingsTest.php
deleted file mode 100644
index 8728dd1f..00000000
--- a/tests/remote/tests/Sync/SettingsTest.php
+++ /dev/null
@@ -1,130 +0,0 @@
-<?php
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-use API2 as API;
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class SettingsSyncTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-		self::$sessionID = Sync::login();
-	}
-	
-	
-	public function tearDown() {
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-	}
-	
-	
-	
-	public function testSettings() {
-		$settingKey = 'tagColors';
-		$value = array(
-			array(
-				"name" => "_READ",
-				"color" => "#990000"
-			)
-		);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$lastSyncTimestamp = (int) $xml['timestamp'];
-		
-		$libraryVersion = API::getLibraryVersion();
-		
-		// Create item via sync
-		$data = '<data version="9"><settings><setting libraryID="'
-			. self::$config['libraryID'] . '" name="' . $settingKey . '">'
-			. htmlspecialchars(json_encode($value))
-			. '</setting></settings></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Check via sync
-		$xml = Sync::updated(self::$sessionID, $lastSyncTimestamp);
-		$updateKey = (string) $xml['updateKey'];
-		$lastSyncTimestamp = $xml['timestamp'];
-		$settingXML = $xml->updated[0]->settings[0]->setting[0];
-		$this->assertEquals(self::$config['libraryID'], (int) $settingXML['libraryID']);
-		$this->assertEquals($settingKey, (string) $settingXML['name']);
-		$this->assertEquals($value, json_decode((string) $settingXML, true));
-		
-		// Get setting via API and check value
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey']
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$json = json_decode($response->getBody(), true);
-		$this->assertNotNull($json);
-		$this->assertEquals($value, $json['value']);
-		$this->assertEquals($libraryVersion + 1, $json['version']);
-		
-		// Delete via sync
-		$xmlstr = '<data version="9">'
-			. '<deleted>'
-			. '<settings>'
-			. '<setting libraryID="' . self::$config['libraryID']
-				. '" key="' . $settingKey . '"/>'
-			. '</settings>'
-			. '</deleted>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		$xml = Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get setting via API and check value
-		$response = API::userGet(
-			self::$config['userID'],
-			"settings/$settingKey?key=" . self::$config['apiKey']
-		);
-		$this->assertEquals(404, $response->getStatus());
-		
-		// Check for missing via sync
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$lastSyncTimestamp = $xml['timestamp'];
-		$this->assertEquals(0, $xml->updated[0]->settings->count());
-		$this->assertEquals(1, $xml->updated[0]->deleted[0]->settings[0]->setting->count());
-		$this->assertEquals(self::$config['libraryID'], (int) $xml->updated[0]->deleted[0]->settings[0]->setting[0]['libraryID']);
-		$this->assertEquals($settingKey, (string) $xml->updated[0]->deleted[0]->settings[0]->setting[0]['key']);
-	}
-}
diff --git a/tests/remote/tests/Sync/SyncTest.php b/tests/remote/tests/Sync/SyncTest.php
deleted file mode 100644
index 61546044..00000000
--- a/tests/remote/tests/Sync/SyncTest.php
+++ /dev/null
@@ -1,129 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-use API2 as API;
-require_once 'include/bootstrap.inc.php';
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class SyncTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-		API::groupClear(self::$config['ownedPublicGroupID']);
-		self::$sessionID = Sync::login();
-	}
-	
-	
-	public function tearDown() {
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-	}
-	
-	
-	public function testSyncEmpty() {
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals("0", (string) $xml['earliest']);
-		$this->assertFalse(isset($xml->updated->items));
-		$this->assertEquals(self::$config['userID'], (int) $xml['userID']);
-	}
-	
-	
-	/**
-	 * @depends testSyncEmpty
-	 */
-	public function testSync() {
-		$xml = Sync::updated(self::$sessionID);
-		
-		// Upload
-		$data = file_get_contents("data/sync1upload.xml");
-		$data = str_replace('libraryID=""', 'libraryID="' . self::$config['libraryID'] . '"', $data);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Download
-		$xml = Sync::updated(self::$sessionID);
-		unset($xml->updated->groups);
-		$xml['timestamp'] = "";
-		$xml['updateKey'] = "";
-		$xml['earliest'] = "";
-		
-		$this->assertXmlStringEqualsXmlFile("data/sync1download.xml", $xml->asXML());
-		
-		// Test fully cached download
-		$xml = Sync::updated(self::$sessionID);
-		unset($xml->updated->groups);
-		$xml['timestamp'] = "";
-		$xml['updateKey'] = "";
-		$xml['earliest'] = "";
-		
-		$this->assertXmlStringEqualsXmlFile("data/sync1download.xml", $xml->asXML());
-		
-		// Test item-level cached download
-		$xml = Sync::updated(self::$sessionID, 2);
-		unset($xml->updated->groups);
-		$xml['timestamp'] = "";
-		$xml['updateKey'] = "";
-		$xml['earliest'] = "";
-		
-		$this->assertXmlStringEqualsXmlFile("data/sync1download.xml", $xml->asXML());
-	}
-	
-	
-	public function testDownloadCache() {
-		$keys = [];
-		$keys[] = API::createItem("book", false, false, 'key');
-		$keys[] = API::createItem("journalArticle", false, false, 'key');
-		$keys[] = API::createItem("newspaperArticle", false, false, 'key');
-		$keys[] = API::createItem("magazineArticle", false, false, 'key');
-		$keys[] = API::createItem("bookSection", false, false, 'key');
-		$keys[] = API::createItem("audioRecording", false, false, 'key');
-		
-		$xml1 = Sync::updated(self::$sessionID);
-		$xml2 = Sync::updated(self::$sessionID);
-		$this->assertEquals(
-			preg_replace('/timestamp="\d+"/', 'timestamp="--"', $xml1->asXML()),
-			preg_replace('/timestamp="\d+"/', 'timestamp="--"', $xml2->asXML())
-		);
-	}
-}
\ No newline at end of file
diff --git a/tests/remote/tests/Sync/TagTest.php b/tests/remote/tests/Sync/TagTest.php
deleted file mode 100644
index f7fd8d56..00000000
--- a/tests/remote/tests/Sync/TagTest.php
+++ /dev/null
@@ -1,634 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2012 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 *****
-*/
-
-use API2 as API;
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class SyncTagTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-		API::groupClear(self::$config['ownedPublicGroupID']);
-		self::$sessionID = Sync::login();
-	}
-	
-	
-	public function tearDown() {
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-	}
-	
-	
-	public function testTagAddItemChange() {
-		$key = 'AAAAAAAA';
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key . '"/></items></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item version via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$version = $data['version'];
-		
-		// Get item via sync
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals(1, sizeOf($xml->updated->items->item));
-		
-		// Increment the library version, since we're testing the
-		// version below
-		API::createItem('newspaperArticle', false, false, 'key');
-		$libraryVersion = API::getLibraryVersion();
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Add tag to item via sync
-		$data = '<data version="9"><tags><tag libraryID="'
-			. self::$config['libraryID'] . '" name="Test" '
-			. 'dateAdded="2009-03-07 04:54:56" '
-			. 'dateModified="2009-03-07 04:54:56" '
-			. 'key="BBBBBBBB">'
-			. '<items>' . $key . '</items>'
-			. '</tag></tags></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$this->assertCount(1, $json->tags);
-		$this->assertTrue(isset($json->tags[0]->tag));
-		$this->assertEquals("Test", $json->tags[0]->tag);
-		// Item version should be increased
-		$this->assertGreaterThan($version, $data['version']);
-		// And should be one more than previous version
-		$this->assertEquals($libraryVersion + 1, $data['version']);
-	}
-	
-	
-	public function testTagModifyItemChange() {
-		$key1 = 'AAAAAAAA';
-		$key2 = 'BBBBBBBB';
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create two items with the same tag via sync
-		$data = '<data version="9"><items>'
-			. '<item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key1 . '"/>'
-			. '<item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2011-03-07 04:53:20" '
-			. 'dateModified="2011-03-07 04:54:09" '
-			. 'key="' . $key2 . '"/>'
-			. '</items>'
-			. '<tags><tag libraryID="'
-			. self::$config['libraryID'] . '" name="Test1" '
-			. 'dateAdded="2009-03-07 04:54:56" '
-			. 'dateModified="2009-03-07 04:54:56" '
-			. 'key="CCCCCCCC">'
-			. '<items>' . $key1 . ' ' . $key2 . '</items>'
-			. '</tag></tags>'
-			.'</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item versions via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key1?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml1 = API::getXMLFromResponse($response);
-		$data1 = API::parseDataFromAtomEntry($xml1);
-		$version1 = $data1['version'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key2?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml2 = API::getXMLFromResponse($response);
-		$data2 = API::parseDataFromAtomEntry($xml2);
-		$version2 = $data2['version'];
-		
-		// Get items via sync
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals(2, sizeOf($xml->updated->items->item));
-		
-		// Increment the library version, since we're testing the
-		// version below
-		API::createItem('newspaperArticle', false, false, 'key');
-		$libraryVersion = API::getLibraryVersion();
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Modify tag on one item via sync
-		$data = '<data version="9">'
-		. '<tags>'
-		. '<tag libraryID="'
-		. self::$config['libraryID'] . '" name="Test1" '
-		. 'dateAdded="2009-03-07 04:54:56" '
-		. 'dateModified="2012-03-07 04:54:56" '
-		. 'key="CCCCCCCC">'
-		. '<items>' . $key2 . '</items>'
-		. '</tag>'
-		. '<tag libraryID="'
-		. self::$config['libraryID'] . '" name="Test2" '
-		. 'dateAdded="2013-03-07 04:54:56" '
-		. 'dateModified="2012-03-08 04:54:56" '
-		. 'key="DDDDDDDD">'
-		. '<items>' . $key1 . '</items>'
-		. '</tag>'
-		. '</tags>'
-		. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get items via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key1?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml1 = API::getXMLFromResponse($response);
-		$data1 = API::parseDataFromAtomEntry($xml1);
-		$json1 = json_decode($data1['content'], true);
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key2?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml2 = API::getXMLFromResponse($response);
-		$data2 = API::parseDataFromAtomEntry($xml2);
-		$json2 = json_decode($data2['content'], true);
-		
-		$this->assertCount(1, $json1['tags']);
-		$this->assertTrue(isset($json1['tags'][0]['tag']));
-		$this->assertEquals("Test2", $json1['tags'][0]['tag']);
-		
-		$this->assertCount(1, $json2['tags']);
-		$this->assertTrue(isset($json2['tags'][0]['tag']));
-		$this->assertEquals("Test1", $json2['tags'][0]['tag']);
-		
-		// Only item 1 version should have changed
-		$this->assertEquals($libraryVersion + 1, $json1['itemVersion']);
-		$this->assertEquals($version2, $json2['itemVersion']);
-	}
-	
-	
-	// Also tests lastModifiedByUserID
-	public function testGroupTagAddItemChange() {
-		$key = 'AAAAAAAA';
-		
-		$groupID = self::$config['ownedPrivateGroupID'];
-		$libraryID = self::$config['ownedPrivateGroupLibraryID'];
-		$sessionID2 = Sync::login(
-			array(
-				'username' => self::$config['username2'],
-				'password' => self::$config['password2']
-			)
-		);
-		$xml = Sync::updated($sessionID2);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="' . $libraryID . '" '
-			. 'itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key . '"/></items></data>';
-		$response = Sync::upload($sessionID2, $updateKey, $data);
-		Sync::waitForUpload($sessionID2, $response, $this);
-		
-		// Get item version via API
-		$response = API::groupGet(
-			$groupID,
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$version = $data['version'];
-		
-		// Verify createdByUserID and lastModifiedByUserID
-		$response = API::groupGet(
-			$groupID,
-			"items/$key?key=" . self::$config['apiKey'] . "&content=none"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$xml->registerXPathNamespace('zxfer', 'http://zotero.org/ns/transfer');
-		$createdByUser = (string) array_get_first($xml->xpath('//atom:entry/atom:author/atom:name'));
-		$lastModifiedByUser = (string) array_get_first($xml->xpath('//atom:entry/zapi:lastModifiedByUser'));
-		$this->assertEquals(self::$config['username2'], $createdByUser);
-		$this->assertEquals(self::$config['username2'], $lastModifiedByUser);
-		
-		// Get item via sync
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$this->assertEquals(1, sizeOf($xml->updated->items->item));
-		
-		// Add tag to item via sync
-		$data = '<data version="9"><tags><tag libraryID="' . $libraryID . '" '
-			. 'name="Test" '
-			. 'dateAdded="2009-03-07 04:54:56" '
-			. 'dateModified="2009-03-07 04:54:56" '
-			. 'key="BBBBBBBB">'
-			. '<items>' . $key . '</items>'
-			. '</tag></tags></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item via API
-		$response = API::groupGet(
-			$groupID,
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$this->assertCount(1, $json->tags);
-		$this->assertTrue(isset($json->tags[0]->tag));
-		$this->assertEquals("Test", $json->tags[0]->tag);
-		$this->assertGreaterThan($version, $data['version']);
-		
-		// Verify createdByUserID and lastModifiedByUserID
-		$response = API::groupGet(
-			$groupID,
-			"items/$key?key=" . self::$config['apiKey'] . "&content=none"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$xml->registerXPathNamespace('zxfer', 'http://zotero.org/ns/transfer');
-		$createdByUser = (string) array_get_first($xml->xpath('//atom:entry/atom:author/atom:name'));
-		$lastModifiedByUser = (string) array_get_first($xml->xpath('//atom:entry/zapi:lastModifiedByUser'));
-		$this->assertEquals(self::$config['username2'], $createdByUser);
-		$this->assertEquals(self::$config['username'], $lastModifiedByUser);
-	}
-	
-	
-	public function testTagAddUnmodifiedItemChange() {
-		$key = 'AAAAAAAA';
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key . '"/></items></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item version via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$version = $data['version'];
-		
-		// Get item via sync
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		$this->assertEquals(1, sizeOf($xml->updated->items->item));
-		
-		// Add tag to item via sync, and include unmodified item
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key . '"/></items>'
-			. '<tags><tag libraryID="'
-			. self::$config['libraryID'] . '" name="Test" '
-			. 'dateAdded="2009-03-07 04:54:56" '
-			. 'dateModified="2009-03-07 04:54:56" '
-			. 'key="BBBBBBBB">'
-			. '<items>' . $key . '</items>'
-			. '</tag></tags></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$this->assertCount(1, $json->tags);
-		$this->assertTrue(isset($json->tags[0]->tag));
-		$this->assertEquals("Test", $json->tags[0]->tag);
-		$this->assertGreaterThan($version, $data['version']);
-	}
-	
-	
-	public function testTagRemoveItemChange() {
-		$key = 'AAAAAAAA';
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key . '"/></items>'
-			. '<tags><tag libraryID="'
-			. self::$config['libraryID'] . '" name="Test" '
-			. 'dateAdded="2009-03-07 04:54:56" '
-			. 'dateModified="2009-03-07 04:54:56" '
-			. 'key="BBBBBBBB">'
-			. '<items>' . $key . '</items>'
-			. '</tag>'
-			. '<tag libraryID="'
-			. self::$config['libraryID'] . '" name="Test2" '
-			. 'dateAdded="2009-03-07 04:54:56" '
-			. 'dateModified="2009-03-07 04:54:56" '
-			. 'key="CCCCCCCC">'
-			. '<items>' . $key . '</items>'
-			. '</tag></tags></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$originalVersion = $data['version'];
-		
-		$this->assertCount(2, $json->tags);
-		$this->assertTrue(isset($json->tags[0]->tag));
-		$this->assertEquals("Test", $json->tags[0]->tag);
-		$this->assertTrue(isset($json->tags[1]->tag));
-		$this->assertEquals("Test2", $json->tags[1]->tag);
-		
-		// Get item via sync
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$this->assertEquals(1, sizeOf($xml->updated->items->item));
-		$this->assertEquals(2, sizeOf($xml->updated->tags->tag));
-		$this->assertEquals(1, sizeOf($xml->updated->tags->tag[0]->items));
-		$this->assertEquals(1, sizeOf($xml->updated->tags->tag[1]->items));
-		
-		// Remove tag from item via sync
-		$data = '<data version="9"><tags><tag libraryID="'
-			. self::$config['libraryID'] . '" name="Test" '
-			. 'dateAdded="2009-03-07 04:54:56" '
-			. 'dateModified="2009-03-07 04:54:56" '
-			. 'key="BBBBBBBB">'
-			. '</tag></tags></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item via sync
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals(2, sizeOf($xml->updated->tags->tag));
-		$this->assertFalse(isset($xml->updated->tags->tag[0]->items));
-		$this->assertEquals(1, sizeOf($xml->updated->tags->tag[1]->items));
-		
-		// Get item version via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$this->assertGreaterThan($originalVersion, $data['version']);
-		$this->assertEquals(1, (int) array_get_first($xml->xpath('/atom:entry/zapi:numTags')));
-		$this->assertCount(1, $json->tags);
-	}
-	
-	
-	public function testTagDeleteItemChange() {
-		$key = 'AAAAAAAA';
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key . '"/></items>'
-			. '<tags><tag libraryID="'
-			. self::$config['libraryID'] . '" name="Test" '
-			. 'dateAdded="2009-03-07 04:54:56" '
-			. 'dateModified="2009-03-07 04:54:56" '
-			. 'key="BBBBBBBB">'
-			. '<items>' . $key . '</items>'
-			. '</tag></tags></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$originalVersion = $data['version'];
-		
-		$this->assertCount(1, $json->tags);
-		$this->assertTrue(isset($json->tags[0]->tag));
-		$this->assertEquals("Test", $json->tags[0]->tag);
-		
-		// Get item via sync
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		$this->assertEquals(1, sizeOf($xml->updated->items->item));
-		$this->assertEquals(1, sizeOf($xml->updated->tags->tag));
-		$this->assertEquals(1, sizeOf($xml->updated->tags->tag[0]->items));
-		
-		// Delete tag via sync
-		$data = '<data version="9"><deleted><tags><tag libraryID="'
-			. self::$config['libraryID'] . '" key="BBBBBBBB"/>'
-			. '</tags></deleted></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item via sync
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals(1, sizeOf(isset($xml->updated->tags->tag)));
-		$this->assertFalse(isset($xml->updated->tags->tag[0]->items));
-		
-		// Get item version via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$this->assertEquals(0, (int) array_get_first($xml->xpath('/atom:entry/zapi:numTags')));
-		$this->assertCount(0, $json->tags);
-		$this->assertGreaterThan($originalVersion, $data['version']);
-	}
-	
-	
-	public function testTagDeleteUnmodifiedItemChange() {
-		$key = 'AAAAAAAA';
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Create item via sync
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key . '"/></items>'
-			. '<tags><tag libraryID="'
-			. self::$config['libraryID'] . '" name="Test" '
-			. 'dateAdded="2009-03-07 04:54:56" '
-			. 'dateModified="2009-03-07 04:54:56" '
-			. 'key="BBBBBBBB">'
-			. '<items>' . $key . '</items>'
-			. '</tag></tags></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		$originalVersion = $data['version'];
-		
-		$this->assertCount(1, $json->tags);
-		$this->assertTrue(isset($json->tags[0]->tag));
-		$this->assertEquals("Test", $json->tags[0]->tag);
-		
-		// Get item via sync
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals(1, sizeOf($xml->updated->items->item));
-		$this->assertEquals(1, sizeOf($xml->updated->tags->tag));
-		$this->assertEquals(1, sizeOf($xml->updated->tags->tag[0]->items));
-		$lastsync = (int) $xml['timestamp'];
-		
-		usleep(1500000);
-		
-		// Increment the library version, since we're testing the
-		// version below
-		API::createItem('newspaperArticle', false, false, 'key');
-		$libraryVersion = API::getLibraryVersion();
-		
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = (string) $xml['updateKey'];
-		
-		// Delete tag via sync, with unmodified item
-		$data = '<data version="9"><items><item libraryID="'
-			. self::$config['libraryID'] . '" itemType="book" '
-			. 'dateAdded="2009-03-07 04:53:20" '
-			. 'dateModified="2009-03-07 04:54:09" '
-			. 'key="' . $key . '"/></items>'
-			. '<deleted><tags><tag libraryID="'
-			. self::$config['libraryID'] . '" key="BBBBBBBB"/>'
-			. '</tags></deleted></data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $data);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Get item via sync
-		$xml = Sync::updated(self::$sessionID);
-		$this->assertEquals(1, sizeOf(isset($xml->updated->tags->tag)));
-		$this->assertFalse(isset($xml->updated->tags->tag[0]->items));
-		
-		// Get item version via API
-		$response = API::userGet(
-			self::$config['userID'],
-			"items/$key?key=" . self::$config['apiKey'] . "&content=json"
-		);
-		$xml = API::getXMLFromResponse($response);
-		$data = API::parseDataFromAtomEntry($xml);
-		$json = json_decode($data['content']);
-		
-		$this->assertEquals(0, (int) array_get_first($xml->xpath('/atom:entry/zapi:numTags')));
-		$this->assertCount(0, $json->tags);
-		// New item version should be greater than before
-		$this->assertGreaterThan($originalVersion, $data['version']);
-		// And should be one more than previous version
-		$this->assertEquals($libraryVersion + 1, $data['version']);
-		
-		// Only the newspaperArticle should be updated
-		$xml = Sync::updated(self::$sessionID, $lastsync);
-		$this->assertEquals(1, $xml->updated[0]->items[0]->count());
-	}
-}
diff --git a/tests/remote/tests/Sync/VersionTest.php b/tests/remote/tests/Sync/VersionTest.php
deleted file mode 100644
index 31790398..00000000
--- a/tests/remote/tests/Sync/VersionTest.php
+++ /dev/null
@@ -1,167 +0,0 @@
-<?
-/*
-    ***** BEGIN LICENSE BLOCK *****
-    
-    This file is part of the Zotero Data Server.
-    
-    Copyright © 2013 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 *****
-*/
-
-use API2 as API;
-require_once 'include/api2.inc.php';
-require_once 'include/sync.inc.php';
-
-class SyncVersionTests extends PHPUnit_Framework_TestCase {
-	protected static $config;
-	protected static $sessionID;
-	
-	public static function setUpBeforeClass() {
-		require 'include/config.inc.php';
-		foreach ($config as $k => $v) {
-			self::$config[$k] = $v;
-		}
-		
-		API::useAPIVersion(2);
-	}
-	
-	
-	public function setUp() {
-		API::userClear(self::$config['userID']);
-		API::groupClear(self::$config['ownedPrivateGroupID']);
-		API::groupClear(self::$config['ownedPublicGroupID']);
-		self::$sessionID = Sync::login();
-	}
-	
-	
-	public function tearDown() {
-		Sync::logout(self::$sessionID);
-		self::$sessionID = null;
-	}
-	
-	
-	/**
-	 * Create and delete an item via sync and check with /deleted?newer=0
-	 */
-	public function testAPINewerTimestamp() {
-		$this->_testAPINewerTimestamp('collection');
-		$this->_testAPINewerTimestamp('item');
-		$this->_testAPINewerTimestamp('search');
-	}
-	
-	
-	private function _testAPINewerTimestamp($objectType) {
-		$objectTypePlural = API::getPluralObjectType($objectType);
-		
-		$xml = Sync::updated(self::$sessionID);
-		$lastSyncTimestamp = $xml['timestamp'];
-		
-		// Create via sync
-		switch ($objectType) {
-		case 'collection':
-			$keys[] = Sync::createCollection(
-				self::$sessionID, self::$config['libraryID'], "Test", false, $this
-			);
-			break;
-		
-		case 'item':
-			$keys[] = Sync::createItem(
-				self::$sessionID, self::$config['libraryID'], "book", false, $this
-			);
-			break;
-		
-		case 'search':
-			$keys[] = Sync::createSearch(
-				self::$sessionID, self::$config['libraryID'], "Test", 'default', $this
-			);
-			break;
-		}
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				. "&newertime=$lastSyncTimestamp&format=keys"
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$responseKeys = explode("\n", trim($response->getBody()));
-		$this->assertCount(sizeOf($keys), $responseKeys);
-		foreach ($keys as $key) {
-			$this->assertContains($key, $responseKeys);
-		}
-		
-		// Should be empty with later timestamp
-		$xml = Sync::updated(self::$sessionID);
-		$lastSyncTimestamp = $xml['timestamp'];
-		
-		$response = API::userGet(
-			self::$config['userID'],
-			"$objectTypePlural?key=" . self::$config['apiKey']
-				// server uses NOW() + 1
-				. "&newertime=" . ($lastSyncTimestamp + 2) . "&format=keys"
-		);
-		$this->assertEquals(200, $response->getStatus());
-		$this->assertEquals("", trim($response->getBody()));
-	}
-	
-	
-	public function testSyncUploadUnchanged() {
-		$data1 = API::createItem("audioRecording", array(
-			"title" => "Test",
-			"relations" => array(
-				'owl:sameAs' => 'http://zotero.org/groups/1/items/AAAAAAAA'
-			)
-		), null, 'data');
-		// dc:relation already exists, so item shouldn't change
-		$data2 = API::createItem("interview", array(
-			"relations" => array(
-				'dc:relation' => 'http://zotero.org/users/'
-					. self::$config['userID'] . '/items/' . $data1['key']
-			)
-		), null, 'data');
-		
-		// Upload unchanged via sync
-		$xml = Sync::updated(self::$sessionID);
-		$updateKey = $xml['updateKey'];
-		$lastSyncTimestamp = $xml['timestamp'];
-		
-		$itemXML1 = array_get_first($xml->updated[0]->items[0]->xpath("item[@key='{$data1['key']}']"));
-		$itemXML2 = array_get_first($xml->updated[0]->items[0]->xpath("item[@key='{$data2['key']}']"));
-		$itemXML1['libraryID'] = self::$config['libraryID'];
-		$itemXML2['libraryID'] = self::$config['libraryID'];
-		
-		$xmlstr = '<data version="9">'
-			. '<items>'
-			. $itemXML1->asXML()
-			. $itemXML2->asXML()
-			. '</items>'
-			. '</data>';
-		$response = Sync::upload(self::$sessionID, $updateKey, $xmlstr);
-		Sync::waitForUpload(self::$sessionID, $response, $this);
-		
-		// Check via API to make sure they're the same
-		$response = API::userGet(
-			self::$config['userID'],
-			"items?key=" . self::$config['apiKey']
-				. "&format=versions"
-		);
-		$json = API::getJSONFromResponse($response);
-		$this->assertEquals($data1['version'], $json[$data1['key']]);
-		$this->assertEquals($data2['version'], $json[$data2['key']]);
-	}
-}
diff --git a/tests/remote/work/.gitignore b/tests/remote/work/.gitignore
deleted file mode 100644
index 5e7d2734..00000000
--- a/tests/remote/work/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# Ignore everything in this directory
-*
-# Except this file
-!.gitignore
diff --git a/tmcs.Dockerfile b/tmcs.Dockerfile
new file mode 100644
index 00000000..438fc8c0
--- /dev/null
+++ b/tmcs.Dockerfile
@@ -0,0 +1,10 @@
+FROM node:7-alpine
+ARG ZOTPRIME_VERSION=2
+
+WORKDIR /usr/src/app
+COPY ./tinymce-clean-server .
+RUN npm install
+
+EXPOSE 16342
+
+CMD [ "npm", "start" ]
diff --git a/tmcs.Dockerfile.dockerignore b/tmcs.Dockerfile.dockerignore
new file mode 100644
index 00000000..a35b3173
--- /dev/null
+++ b/tmcs.Dockerfile.dockerignore
@@ -0,0 +1,21 @@
+
+**/secret.json
+**/secret.txt
+**/secret.yaml
+.env
+.git
+.github
+.vscode/
+bin
+build
+client
+dataserver
+doc
+docker
+docker-compose.yml
+logs
+stream-server
+#tinymce-clean-server
+Zend
+zotprime-k8s
+
diff --git a/tmp/.gitignore b/tmp/.gitignore
deleted file mode 100644
index 5e7d2734..00000000
--- a/tmp/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# Ignore everything in this directory
-*
-# Except this file
-!.gitignore
diff --git a/vendor/.gitignore b/vendor/.gitignore
deleted file mode 100644
index 5e7d2734..00000000
--- a/vendor/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# Ignore everything in this directory
-*
-# Except this file
-!.gitignore
diff --git a/zotprime-k8s/GKE/GCP_workaround_secret.yaml b/zotprime-k8s/GKE/GCP_workaround_secret.yaml
new file mode 100644
index 00000000..5921785e
--- /dev/null
+++ b/zotprime-k8s/GKE/GCP_workaround_secret.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: web-ssl
+  namespace: zotprime
+type: kubernetes.io/tls
+stringData:
+  tls.key: ""
+  tls.crt: ""
diff --git a/zotprime-k8s/GKE/helm-chart/.helmignore b/zotprime-k8s/GKE/helm-chart/.helmignore
new file mode 100644
index 00000000..0e8a0eb3
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/zotprime-k8s/GKE/helm-chart/Chart.yaml b/zotprime-k8s/GKE/helm-chart/Chart.yaml
new file mode 100644
index 00000000..8923f3f3
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/Chart.yaml
@@ -0,0 +1,21 @@
+apiVersion: v2
+name: zotprime-k8s
+description: A Helm chart for Kubernetes
+# A chart can be either an 'application' or a 'library' chart.
+#
+# Application charts are a collection of templates that can be packaged into versioned archives
+# to be deployed.
+#
+# Library charts provide useful utilities or functions for the chart developer. They're included as
+# a dependency of application charts to inject those utilities and functions into the rendering
+# pipeline. Library charts do not define any templates and therefore cannot be deployed.
+type: application
+# This is the chart version. This version number should be incremented each time you make changes
+# to the chart and its templates, including the app version.
+# Versions are expected to follow Semantic Versioning (https://semver.org/)
+version: 0.1.6
+# This is the version number of the application being deployed. This version number should be
+# incremented each time you make changes to the application. Versions are not expected to
+# follow Semantic Versioning. They should reflect the version the application is using.
+# It is recommended to use it with quotes.
+appVersion: "2.6.1-rc"
diff --git a/zotprime-k8s/GKE/helm-chart/templates/_helpers.tpl b/zotprime-k8s/GKE/helm-chart/templates/_helpers.tpl
new file mode 100644
index 00000000..4d249735
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/_helpers.tpl
@@ -0,0 +1,62 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "zotprime-k8s.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
+If release name contains chart name it will be used as a full name.
+*/}}
+{{- define "zotprime-k8s.fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default .Chart.Name .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
+{{/*
+Create chart name and version as used by the chart label.
+*/}}
+{{- define "zotprime-k8s.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels
+*/}}
+{{- define "zotprime-k8s.labels" -}}
+helm.sh/chart: {{ include "zotprime-k8s.chart" . }}
+{{ include "zotprime-k8s.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Selector labels
+*/}}
+{{- define "zotprime-k8s.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "zotprime-k8s.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Create the name of the service account to use
+*/}}
+{{- define "zotprime-k8s.serviceAccountName" -}}
+{{- if .Values.serviceAccount.create }}
+{{- default (include "zotprime-k8s.fullname" .) .Values.serviceAccount.name }}
+{{- else }}
+{{- default "default" .Values.serviceAccount.name }}
+{{- end }}
+{{- end }}
diff --git a/zotprime-k8s/GKE/helm-chart/templates/dataminio.yaml b/zotprime-k8s/GKE/helm-chart/templates/dataminio.yaml
new file mode 100644
index 00000000..5186c4ae
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/dataminio.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-dataminio
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  accessModes:
+  - ReadWriteOnce
+  resources:
+    requests:
+      storage: {{ .Values.pvc.dataminio.storageRequest | quote }}
+  storageClassName: {{ .Values.pvc.dataminio.storageClass | quote }}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/dataserver-config.yaml b/zotprime-k8s/GKE/helm-chart/templates/dataserver-config.yaml
new file mode 100644
index 00000000..9913508d
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/dataserver-config.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-dataserver-config
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  init.sh: {{ .Values.dataserverConfig.initSh | toYaml | indent 1 }}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/dataserver.yaml b/zotprime-k8s/GKE/helm-chart/templates/dataserver.yaml
new file mode 100644
index 00000000..8f83fcb0
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/dataserver.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: dataserver
+  labels:
+    apps: dataserver
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+  annotations:
+    cloud.google.com/neg: '{"ingress": true}'
+spec:
+  type: {{ .Values.dataserver.type }}
+  selector:
+    apps: zotprime-dataserver
+  ports:
+	{{- .Values.dataserver.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/db-config.yaml b/zotprime-k8s/GKE/helm-chart/templates/db-config.yaml
new file mode 100644
index 00000000..3307794f
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/db-config.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-db-config
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  mariadb-databasename: {{ .Values.dbConfig.mariadbDatabasename | quote }}
+  mariadb-user: {{ .Values.dbConfig.mariadbUser | quote }}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/db-secret.yaml b/zotprime-k8s/GKE/helm-chart/templates/db-secret.yaml
new file mode 100644
index 00000000..91e8e829
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/db-secret.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-db-secret
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  secret-db.txt: {{ required "dbSecret.secretTxt is required" .Values.dbSecret.secretTxt }}
+type: Opaque
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/deployment.yaml b/zotprime-k8s/GKE/helm-chart/templates/deployment.yaml
new file mode 100644
index 00000000..46f113c2
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/deployment.yaml
@@ -0,0 +1,532 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-dataserver
+  labels:
+    apps: zotprime-dataserver
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeDataserver.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-dataserver
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-dataserver
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - env:
+        - name: DSURI
+          value: {{ quote .Values.zotprimeDataserver.zotprimeDataserver.env.dsuri }}
+        - name: S3POINTURI
+          value: {{ quote .Values.zotprimeDataserver.zotprimeDataserver.env.s3Pointuri
+            }}
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeDataserver.zotprimeDataserver.image.repository }}:{{
+          .Values.zotprimeDataserver.zotprimeDataserver.image.tag | default .Chart.AppVersion
+          }}
+        imagePullPolicy: {{ .Values.zotprimeDataserver.zotprimeDataserver.imagePullPolicy
+          }}
+        lifecycle:
+          postStart:
+            exec:
+              command:
+              - /bin/sh
+              - -xc
+              - sleep 180; bash /tmp/_conf/init.sh ; exit 0
+        livenessProbe:
+          httpGet:
+            path: /
+            port: 80
+          initialDelaySeconds: 195
+          periodSeconds: 30
+        name: zotprime-dataserver
+        ports:
+        - containerPort: 80
+        readinessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /
+            port: 80
+          initialDelaySeconds: 190
+          periodSeconds: 30
+          successThreshold: 1
+          timeoutSeconds: 1
+        resources: {{- toYaml .Values.zotprimeDataserver.zotprimeDataserver.resources |
+          nindent 10 }}
+        volumeMounts:
+        - mountPath: /tmp/_conf
+          name: dataserver-config
+          readOnly: true
+      hostAliases:
+      - hostnames:
+        - s3min.projectdev.net
+        ip: 10.30.11.11
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-low-priority
+      restartPolicy: Always
+      securityContext: {}
+      volumes:
+      - configMap:
+          name: {{ include "zotprime-k8s.fullname" . }}-dataserver-config
+        name: dataserver-config
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-localstack
+  labels:
+    apps: zotprime-localstack
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeLocalstack.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-localstack
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-localstack
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - env:
+        - name: SERVICES
+          value: {{ quote .Values.zotprimeLocalstack.localstack.env.services }}
+        - name: DEBUG
+          value: {{ quote .Values.zotprimeLocalstack.localstack.env.debug }}
+        - name: LOCALSTACK_K8S_SERVICE_NAME
+          value: {{ quote .Values.zotprimeLocalstack.localstack.env.localstackK8SServiceName
+            }}
+        - name: LOCALSTACK_K8S_NAMESPACE
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.namespace
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeLocalstack.localstack.image.repository }}:{{ .Values.zotprimeLocalstack.localstack.image.tag
+          | default .Chart.AppVersion }}
+        imagePullPolicy: {{ .Values.zotprimeLocalstack.localstack.imagePullPolicy }}
+        name: localstack
+        ports:
+        - containerPort: 4566
+          name: edge
+          protocol: TCP
+        - containerPort: 4575
+          name: sns-4575
+          protocol: TCP
+        - containerPort: 4576
+          name: sqs-4576
+          protocol: TCP
+        - containerPort: 4567
+          name: apigateway-4567
+          protocol: TCP
+        - containerPort: 4510
+          name: ext-svc-4510
+          protocol: TCP
+        - containerPort: 4511
+          name: ext-svc-4511
+          protocol: TCP
+        - containerPort: 4512
+          name: ext-svc-4512
+          protocol: TCP
+        - containerPort: 4513
+          name: ext-svc-4513
+          protocol: TCP
+        - containerPort: 4514
+          name: ext-svc-4514
+          protocol: TCP
+        - containerPort: 4515
+          name: ext-svc-4515
+          protocol: TCP
+        - containerPort: 4516
+          name: ext-svc-4516
+          protocol: TCP
+        - containerPort: 4517
+          name: ext-svc-4517
+          protocol: TCP
+        - containerPort: 4518
+          name: ext-svc-4518
+          protocol: TCP
+        - containerPort: 4519
+          name: ext-svc-4519
+          protocol: TCP
+        - containerPort: 4520
+          name: ext-svc-4520
+          protocol: TCP
+        - containerPort: 4521
+          name: ext-svc-4521
+          protocol: TCP
+        - containerPort: 4522
+          name: ext-svc-4522
+          protocol: TCP
+        - containerPort: 4523
+          name: ext-svc-4523
+          protocol: TCP
+        - containerPort: 4524
+          name: ext-svc-4524
+          protocol: TCP
+        - containerPort: 4525
+          name: ext-svc-4525
+          protocol: TCP
+        - containerPort: 4526
+          name: ext-svc-4526
+          protocol: TCP
+        - containerPort: 4527
+          name: ext-svc-4527
+          protocol: TCP
+        - containerPort: 4528
+          name: ext-svc-4528
+          protocol: TCP
+        - containerPort: 4529
+          name: ext-svc-4529
+          protocol: TCP
+        - containerPort: 4530
+          name: ext-svc-4530
+          protocol: TCP
+        - containerPort: 4531
+          name: ext-svc-4531
+          protocol: TCP
+        - containerPort: 4532
+          name: ext-svc-4532
+          protocol: TCP
+        - containerPort: 4533
+          name: ext-svc-4533
+          protocol: TCP
+        - containerPort: 4534
+          name: ext-svc-4534
+          protocol: TCP
+        - containerPort: 4535
+          name: ext-svc-4535
+          protocol: TCP
+        - containerPort: 4536
+          name: ext-svc-4536
+          protocol: TCP
+        - containerPort: 4537
+          name: ext-svc-4537
+          protocol: TCP
+        - containerPort: 4538
+          name: ext-svc-4538
+          protocol: TCP
+        - containerPort: 4539
+          name: ext-svc-4539
+          protocol: TCP
+        - containerPort: 4540
+          name: ext-svc-4540
+          protocol: TCP
+        - containerPort: 4541
+          name: ext-svc-4541
+          protocol: TCP
+        - containerPort: 4542
+          name: ext-svc-4542
+          protocol: TCP
+        - containerPort: 4543
+          name: ext-svc-4543
+          protocol: TCP
+        - containerPort: 4544
+          name: ext-svc-4544
+          protocol: TCP
+        - containerPort: 4545
+          name: ext-svc-4545
+          protocol: TCP
+        - containerPort: 4546
+          name: ext-svc-4546
+          protocol: TCP
+        - containerPort: 4547
+          name: ext-svc-4547
+          protocol: TCP
+        - containerPort: 4548
+          name: ext-svc-4548
+          protocol: TCP
+        - containerPort: 4549
+          name: ext-svc-4549
+          protocol: TCP
+        - containerPort: 4550
+          name: ext-svc-4550
+          protocol: TCP
+        - containerPort: 4551
+          name: ext-svc-4551
+          protocol: TCP
+        - containerPort: 4552
+          name: ext-svc-4552
+          protocol: TCP
+        - containerPort: 4553
+          name: ext-svc-4553
+          protocol: TCP
+        - containerPort: 4554
+          name: ext-svc-4554
+          protocol: TCP
+        - containerPort: 4555
+          name: ext-svc-4555
+          protocol: TCP
+        - containerPort: 4556
+          name: ext-svc-4556
+          protocol: TCP
+        - containerPort: 4557
+          name: ext-svc-4557
+          protocol: TCP
+        - containerPort: 4558
+          name: ext-svc-4558
+          protocol: TCP
+        - containerPort: 4559
+          name: ext-svc-4559
+          protocol: TCP
+        resources: {{- toYaml .Values.zotprimeLocalstack.localstack.resources | nindent
+          10 }}
+        securityContext: {{- toYaml .Values.zotprimeLocalstack.localstack.containerSecurityContext
+          | nindent 10 }}
+      securityContext: {}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-memcached
+  labels:
+    apps: zotprime-memcached
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeMemcached.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-memcached
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-memcached
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - args: {{- toYaml .Values.zotprimeMemcached.memcached.args | nindent 8 }}
+        env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeMemcached.memcached.image.repository }}:{{ .Values.zotprimeMemcached.memcached.image.tag
+          | default .Chart.AppVersion }}
+        name: memcached
+        ports:
+        - containerPort: 11211
+          protocol: TCP
+        resources: {{- toYaml .Values.zotprimeMemcached.memcached.resources | nindent 10
+          }}
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-high-priority
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-minio
+  labels:
+    apps: zotprime-minio
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeMinio.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-minio
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-minio
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - args: {{- toYaml .Values.zotprimeMinio.zotprimeMinio.args | nindent 8 }}
+        command:
+        - /bin/bash
+        - -c
+        env:
+        - name: MINIO_ROOT_USER
+          valueFrom:
+            configMapKeyRef:
+              key: minio-user
+              name: {{ include "zotprime-k8s.fullname" . }}-minio-config
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeMinio.zotprimeMinio.image.repository }}:{{ .Values.zotprimeMinio.zotprimeMinio.image.tag
+          | default .Chart.AppVersion }}
+        imagePullPolicy: {{ .Values.zotprimeMinio.zotprimeMinio.imagePullPolicy }}
+        livenessProbe:
+          httpGet:
+            path: /minio/health/live
+            port: 9000
+          initialDelaySeconds: 20
+          periodSeconds: 30
+          timeoutSeconds: 20
+        name: zotprime-minio
+        ports:
+        - containerPort: 9000
+          name: data
+        - containerPort: 9001
+          name: ui
+        readinessProbe:
+          failureThreshold: 5
+          httpGet:
+            path: /minio/health/live
+            port: 9000
+          initialDelaySeconds: 10
+          periodSeconds: 25
+          successThreshold: 1
+          timeoutSeconds: 1
+        resources: {{- toYaml .Values.zotprimeMinio.zotprimeMinio.resources | nindent 10
+          }}
+        volumeMounts:
+        - mountPath: /data
+          name: s3data
+        - mountPath: /tmp/_key
+          name: {{ include "zotprime-k8s.fullname" . }}-minio-secret
+          readOnly: true
+      - env:
+        - name: MINIO_ROOT_USER
+          valueFrom:
+            configMapKeyRef:
+              key: minio-user
+              name: {{ include "zotprime-k8s.fullname" . }}-minio-config
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeMinio.zotprimeMiniomc.image.repository }}:{{ .Values.zotprimeMinio.zotprimeMiniomc.image.tag
+          | default .Chart.AppVersion }}
+        name: zotprime-miniomc
+        resources: {{- toYaml .Values.zotprimeMinio.zotprimeMiniomc.resources | nindent
+          10 }}
+        volumeMounts:
+        - mountPath: /data
+          name: s3data
+        - mountPath: /tmp/_key
+          name: {{ include "zotprime-k8s.fullname" . }}-minio-secret
+          readOnly: true
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-high-priority
+      restartPolicy: Always
+      securityContext: {}
+      volumes:
+      - name: s3data
+        persistentVolumeClaim:
+          claimName: {{ include "zotprime-k8s.fullname" . }}-dataminio
+      - name: {{ include "zotprime-k8s.fullname" . }}-minio-secret
+        secret:
+          secretName: {{ include "zotprime-k8s.fullname" . }}-minio-secret
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-phpmyadmin
+  labels:
+    apps: zotprime-phpmyadmin
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  selector:
+    matchLabels:
+      apps: zotprime-phpmyadmin
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-phpmyadmin
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - env:
+        - name: PMA_HOST
+          value: {{ quote .Values.zotprimePhpmyadmin.phpmyadmin.env.pmaHost }}
+        - name: PMA_PORT
+          value: {{ quote .Values.zotprimePhpmyadmin.phpmyadmin.env.pmaPort }}
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimePhpmyadmin.phpmyadmin.image.repository }}:{{ .Values.zotprimePhpmyadmin.phpmyadmin.image.tag
+          | default .Chart.AppVersion }}
+        name: phpmyadmin
+        ports:
+        - containerPort: 80
+        resources: {{- toYaml .Values.zotprimePhpmyadmin.phpmyadmin.resources | nindent
+          10 }}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-streamserver
+  labels:
+    apps: zotprime-streamserver
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeStreamserver.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-streamserver
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-streamserver
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeStreamserver.zotprimeStreamserver.image.repository }}:{{
+          .Values.zotprimeStreamserver.zotprimeStreamserver.image.tag | default .Chart.AppVersion
+          }}
+        livenessProbe:
+          httpGet:
+            path: /health
+            port: 81
+          initialDelaySeconds: 10
+          periodSeconds: 30
+          timeoutSeconds: 20
+        name: zotprime-streamserver
+        ports:
+        - containerPort: 81
+        readinessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /health
+            port: 81
+          periodSeconds: 20
+          successThreshold: 1
+          timeoutSeconds: 1
+        resources: {{- toYaml .Values.zotprimeStreamserver.zotprimeStreamserver.resources
+          | nindent 10 }}
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-medium-priority
+      restartPolicy: Always
+      securityContext: {}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-tinymceclean
+  labels:
+    apps: zotprime-tinymceclean
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeTinymceclean.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-tinymceclean
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-tinymceclean
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeTinymceclean.zotprimeTinymceclean.image.repository }}:{{
+          .Values.zotprimeTinymceclean.zotprimeTinymceclean.image.tag | default .Chart.AppVersion
+          }}
+        imagePullPolicy: {{ .Values.zotprimeTinymceclean.zotprimeTinymceclean.imagePullPolicy
+          }}
+        name: zotprime-tinymceclean
+        ports:
+        - containerPort: 16342
+        resources: {{- toYaml .Values.zotprimeTinymceclean.zotprimeTinymceclean.resources
+          | nindent 10 }}
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-medium-priority
+      restartPolicy: Always
+      securityContext: {}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/elasticsearch.yaml b/zotprime-k8s/GKE/helm-chart/templates/elasticsearch.yaml
new file mode 100644
index 00000000..51f333cf
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/elasticsearch.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: elasticsearch
+  labels:
+    apps: elasticsearch
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.elasticsearch.type }}
+  selector:
+    apps: zotprime-elasticsearch
+  ports:
+	{{- .Values.elasticsearch.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/high-priority.yaml b/zotprime-k8s/GKE/helm-chart/templates/high-priority.yaml
new file mode 100644
index 00000000..13f2ab36
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/high-priority.yaml
@@ -0,0 +1,9 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-high-priority
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
+value: 1000000
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/localstack.yaml b/zotprime-k8s/GKE/helm-chart/templates/localstack.yaml
new file mode 100644
index 00000000..03146da4
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/localstack.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: localstack
+  labels:
+    apps: localstack
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.localstack.type }}
+  selector:
+    apps: zotprime-localstack
+  ports:
+	{{- .Values.localstack.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/low-priority.yaml b/zotprime-k8s/GKE/helm-chart/templates/low-priority.yaml
new file mode 100644
index 00000000..99fb1f52
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/low-priority.yaml
@@ -0,0 +1,9 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-low-priority
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
+value: 800000
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/medium-priority.yaml b/zotprime-k8s/GKE/helm-chart/templates/medium-priority.yaml
new file mode 100644
index 00000000..07a6de1d
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/medium-priority.yaml
@@ -0,0 +1,9 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-medium-priority
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
+value: 900000
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/memcached.yaml b/zotprime-k8s/GKE/helm-chart/templates/memcached.yaml
new file mode 100644
index 00000000..c4f5c634
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/memcached.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: memcached
+  labels:
+    apps: memcached
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.memcached.type }}
+  selector:
+    apps: zotprime-memcached
+  ports:
+	{{- .Values.memcached.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/minio-config.yaml b/zotprime-k8s/GKE/helm-chart/templates/minio-config.yaml
new file mode 100644
index 00000000..1832f76f
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/minio-config.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-minio-config
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  minio-user: {{ .Values.minioConfig.minioUser | quote }}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/minio-secret.yaml b/zotprime-k8s/GKE/helm-chart/templates/minio-secret.yaml
new file mode 100644
index 00000000..18251c1d
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/minio-secret.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-minio-secret
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  secret-minio.txt: {{ required "minioSecret.secretTxt is required" .Values.minioSecret.secretTxt }}
+type: Opaque
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/minio.yaml b/zotprime-k8s/GKE/helm-chart/templates/minio.yaml
new file mode 100644
index 00000000..c043bc85
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/minio.yaml
@@ -0,0 +1,16 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: minio
+  labels:
+    apps: minio
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+  annotations:
+    cloud.google.com/neg: '{"ingress": true}'
+spec:
+  type: {{ .Values.minio.type }}
+  clusterIP: 10.30.11.11
+  selector:
+    apps: zotprime-minio
+  ports:
+	{{- .Values.minio.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/mysql.yaml b/zotprime-k8s/GKE/helm-chart/templates/mysql.yaml
new file mode 100644
index 00000000..4a5ec55c
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/mysql.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: mysql
+  labels:
+    apps: mysql
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+  annotations:
+    service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
+spec:
+  type: {{ .Values.mysql.type }}
+  selector:
+    apps: zotprime-db
+  ports:
+	{{- .Values.mysql.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/phpmyadmin.yaml b/zotprime-k8s/GKE/helm-chart/templates/phpmyadmin.yaml
new file mode 100644
index 00000000..9dee1ede
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/phpmyadmin.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: phpmyadmin
+  labels:
+    apps: phpmyadmin
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+  annotations:
+    cloud.google.com/neg: '{"ingress": true}'
+spec:
+  type: {{ .Values.phpmyadmin.type }}
+  selector:
+    apps: zotprime-phpmyadmin
+  ports:
+	{{- .Values.phpmyadmin.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/redis-config.yaml b/zotprime-k8s/GKE/helm-chart/templates/redis-config.yaml
new file mode 100644
index 00000000..68c51a07
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/redis-config.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-redis-config
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  redis.conf: {{ .Values.redisConfig.redisConf | toYaml | indent 1 }}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/redis-probes.yaml b/zotprime-k8s/GKE/helm-chart/templates/redis-probes.yaml
new file mode 100644
index 00000000..c5adb693
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/redis-probes.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-redis-probes
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  liveness.sh: {{ .Values.redisProbes.livenessSh | toYaml | indent 1 }}
+  readiness.sh: {{ .Values.redisProbes.readinessSh | toYaml | indent 1 }}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/redis.yaml b/zotprime-k8s/GKE/helm-chart/templates/redis.yaml
new file mode 100644
index 00000000..61753cb3
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/redis.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: redis
+  labels:
+    apps: redis
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.redis.type }}
+  selector:
+    apps: zotprime-redis
+  ports:
+	{{- .Values.redis.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/statefulset.yaml b/zotprime-k8s/GKE/helm-chart/templates/statefulset.yaml
new file mode 100644
index 00000000..589b068e
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/statefulset.yaml
@@ -0,0 +1,266 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-db
+  labels:
+    apps: zotprime-db
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeDb.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-db
+  serviceName: db-service
+  template:
+    metadata:
+      labels:
+        apps: zotprime-db
+    spec:
+      containers:
+      - args: {{- toYaml .Values.zotprimeDb.mariadb.args | nindent 8 }}
+        command:
+        - /bin/bash
+        - -c
+        env:
+        - name: MARIADB_USER
+          valueFrom:
+            configMapKeyRef:
+              key: mariadb-user
+              name: {{ include "zotprime-k8s.fullname" . }}-db-config
+        - name: MARIADB_DATABASE
+          valueFrom:
+            configMapKeyRef:
+              key: mariadb-databasename
+              name: {{ include "zotprime-k8s.fullname" . }}-db-config
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeDb.mariadb.image.repository }}:{{ .Values.zotprimeDb.mariadb.image.tag
+          | default .Chart.AppVersion }}
+        name: mariadb
+        ports:
+        - containerPort: 3306
+          name: mariadb-port
+        resources: {{- toYaml .Values.zotprimeDb.mariadb.resources | nindent 10 }}
+        securityContext: {{- toYaml .Values.zotprimeDb.mariadb.containerSecurityContext
+          | nindent 10 }}
+        volumeMounts:
+        - mountPath: /var/lib/mysql/
+          name: datadb
+        - mountPath: tmp/_key/
+          name: {{ include "zotprime-k8s.fullname" . }}-db-secret
+      initContainers:
+      - command:
+        - chown
+        - -R
+        - 999:999
+        - /var/lib/mysql
+        env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeDb.takeDataDirOwnership.image.repository }}:{{ .Values.zotprimeDb.takeDataDirOwnership.image.tag
+          | default .Chart.AppVersion }}
+        name: take-data-dir-ownership
+        resources: {}
+        volumeMounts:
+        - mountPath: /var/lib/mysql
+          name: datadb
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-high-priority
+      volumes:
+      - name: {{ include "zotprime-k8s.fullname" . }}-db-secret
+        secret:
+          secretName: {{ include "zotprime-k8s.fullname" . }}-db-secret
+  updateStrategy: {}
+  volumeClaimTemplates:
+  - metadata:
+      creationTimestamp: null
+      name: datadb
+    spec:
+      accessModes:
+      - ReadWriteOnce
+      resources: {{ .Values.zotprimeDb.volumeClaims.datadb | toYaml | nindent 8 }}
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-elasticsearch
+  labels:
+    apps: zotprime-elasticsearch
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeElasticsearch.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-elasticsearch
+  serviceName: elasticsearch-masterservice
+  template:
+    metadata:
+      labels:
+        apps: zotprime-elasticsearch
+    spec:
+      containers:
+      - env:
+        - name: CLUSTER_NAME
+          value: {{ quote .Values.zotprimeElasticsearch.elasticsearch.env.clusterName
+            }}
+        - name: xpack.security.enabled
+          value: {{ quote .Values.zotprimeElasticsearch.elasticsearch.env.xpackSecurityEnabled
+            }}
+        - name: cluster.routing.allocation.disk.threshold_enabled
+          value: {{ quote .Values.zotprimeElasticsearch.elasticsearch.env.clusterRoutingAllocationDiskThresholdEnabled
+            }}
+        - name: discovery.type
+          value: {{ quote .Values.zotprimeElasticsearch.elasticsearch.env.discoveryType
+            }}
+        - name: NAMESPACE
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.namespace
+        - name: PROCESSORS
+          valueFrom:
+            resourceFieldRef:
+              divisor: "0"
+              resource: limits.cpu
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeElasticsearch.elasticsearch.image.repository }}:{{
+          .Values.zotprimeElasticsearch.elasticsearch.image.tag | default .Chart.AppVersion
+          }}
+        name: elasticsearch
+        ports:
+        - containerPort: 9300
+          name: transport
+        resources: {{- toYaml .Values.zotprimeElasticsearch.elasticsearch.resources
+          | nindent 10 }}
+        volumeMounts:
+        - mountPath: /data
+          name: dataes
+      initContainers:
+      - command:
+        - sysctl
+        - -w
+        - vm.max_map_count=262144
+        env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeElasticsearch.initSysctl.image.repository }}:{{ .Values.zotprimeElasticsearch.initSysctl.image.tag
+          | default .Chart.AppVersion }}
+        name: init-sysctl
+        resources: {}
+        securityContext: {{- toYaml .Values.zotprimeElasticsearch.initSysctl.containerSecurityContext
+          | nindent 10 }}
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-high-priority
+  updateStrategy: {}
+  volumeClaimTemplates:
+  - metadata:
+      creationTimestamp: null
+      name: dataes
+    spec:
+      accessModes:
+      - ReadWriteOnce
+      resources: {{ .Values.zotprimeElasticsearch.volumeClaims.dataes | toYaml | nindent
+        8 }}
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-redis
+  labels:
+    apps: zotprime-redis
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeRedis.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-redis
+  serviceName: redis-service
+  template:
+    metadata:
+      labels:
+        apps: zotprime-redis
+    spec:
+      affinity:
+        podAntiAffinity:
+          preferredDuringSchedulingIgnoredDuringExecution:
+          - podAffinityTerm:
+              labelSelector:
+                matchExpressions:
+                - key: app
+                  operator: In
+                  values:
+                  - redis
+              topologyKey: kubernetes.io/hostname
+            weight: 100
+      containers:
+      - args: {{- toYaml .Values.zotprimeRedis.redis.args | nindent 8 }}
+        command:
+        - redis-server
+        env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeRedis.redis.image.repository }}:{{ .Values.zotprimeRedis.redis.image.tag
+          | default .Chart.AppVersion }}
+        livenessProbe:
+          exec:
+            command:
+            - sh
+            - -c
+            - /probes/liveness.sh
+          failureThreshold: 5
+          periodSeconds: 5
+          successThreshold: 1
+          timeoutSeconds: 5
+        name: redis
+        ports:
+        - containerPort: 6379
+          name: redis
+          protocol: TCP
+        - containerPort: 16379
+          name: cluster
+          protocol: TCP
+        readinessProbe:
+          exec:
+            command:
+            - sh
+            - -c
+            - /probes/readiness.sh
+          failureThreshold: 5
+          periodSeconds: 5
+          successThreshold: 1
+          timeoutSeconds: 1
+        resources: {{- toYaml .Values.zotprimeRedis.redis.resources | nindent 10 }}
+        startupProbe:
+          failureThreshold: 20
+          periodSeconds: 5
+          successThreshold: 1
+          tcpSocket:
+            port: redis
+          timeoutSeconds: 5
+        volumeMounts:
+        - mountPath: /conf
+          name: conf
+        - mountPath: /data
+          name: dataredis
+        - mountPath: /probes
+          name: probes
+          readOnly: true
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-high-priority
+      terminationGracePeriodSeconds: 20
+      volumes:
+      - configMap:
+          defaultMode: 493
+          name: {{ include "zotprime-k8s.fullname" . }}-redis-config
+        name: conf
+      - configMap:
+          defaultMode: 365
+          name: {{ include "zotprime-k8s.fullname" . }}-redis-probes
+        name: probes
+  updateStrategy: {}
+  volumeClaimTemplates:
+  - metadata:
+      creationTimestamp: null
+      name: dataredis
+    spec:
+      accessModes:
+      - ReadWriteOnce
+      resources: {{ .Values.zotprimeRedis.volumeClaims.dataredis | toYaml | nindent
+        8 }}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/streamserver-backendconfig.yaml b/zotprime-k8s/GKE/helm-chart/templates/streamserver-backendconfig.yaml
new file mode 100644
index 00000000..8a01da7d
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/streamserver-backendconfig.yaml
@@ -0,0 +1,8 @@
+apiVersion: cloud.google.com/v1
+kind: BackendConfig
+metadata:
+  name: streamserver-backendconfig
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  timeoutSec: 3600
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/streamserver.yaml b/zotprime-k8s/GKE/helm-chart/templates/streamserver.yaml
new file mode 100644
index 00000000..abab08de
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/streamserver.yaml
@@ -0,0 +1,16 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: streamserver
+  labels:
+    apps: streamserver
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+  annotations:
+    cloud.google.com/backend-config: '{"default": "streamserver-backendconfig"}'
+    cloud.google.com/neg: '{"ingress": true}'
+spec:
+  type: {{ .Values.streamserver.type }}
+  selector:
+    apps: zotprime-streamserver
+  ports:
+	{{- .Values.streamserver.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/tinymceclean.yaml b/zotprime-k8s/GKE/helm-chart/templates/tinymceclean.yaml
new file mode 100644
index 00000000..0bb576a6
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/tinymceclean.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: tinymceclean
+  labels:
+    apps: tinymceclean
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.tinymceclean.type }}
+  selector:
+    apps: zotprime-tinymceclean
+  ports:
+	{{- .Values.tinymceclean.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/zotprime-ingress-http.yaml b/zotprime-k8s/GKE/helm-chart/templates/zotprime-ingress-http.yaml
new file mode 100644
index 00000000..42625522
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/zotprime-ingress-http.yaml
@@ -0,0 +1,61 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-ingress-http
+  labels:
+    apps: zotprime-ingress-http
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+  annotations:
+    cloud.google.com/load-balancer-type: External
+    kubernetes.io/ingress.class: gce
+#    cert-manager.io/issuer: letsencrypt-staging
+    nginx.ingress.kubernetes.io/rewrite-target: /
+spec:
+#  tls:
+#   - secretName: web-ssl
+#     hosts:
+#      - {{ .Values.ingressHostnames.minios3Web }}
+#      - {{ .Values.ingressHostnames.minios3Data }}
+#      - {{ .Values.ingressHostnames.phpmyadmin }}
+#      - {{ .Values.ingressHostnames.api }}
+  rules:
+  - host: {{ .Values.ingressHostnames.minios3Web }}
+    http:
+      paths:
+      - backend:
+          service:
+            name: minio
+            port:
+              name: minio-ui
+        path: /
+        pathType: Prefix
+  - host: {{ .Values.ingressHostnames.minios3Data }}
+    http:
+      paths:
+      - backend:
+          service:
+            name: minio
+            port:
+              name: minio-data
+        path: /
+        pathType: Prefix
+  - host: {{ .Values.ingressHostnames.phpmyadmin }}
+    http:
+      paths:
+      - backend:
+          service:
+            name: phpmyadmin
+            port:
+              number: 80
+        path: /
+        pathType: Prefix
+  - host: {{ .Values.ingressHostnames.api }}
+    http:
+      paths:
+      - backend:
+          service:
+            name: dataserver
+            port:
+              name: dataserver-api
+        path: /
+        pathType: Prefix
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/templates/zotprime-ingress-websocket.yaml b/zotprime-k8s/GKE/helm-chart/templates/zotprime-ingress-websocket.yaml
new file mode 100644
index 00000000..2d79ed91
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/templates/zotprime-ingress-websocket.yaml
@@ -0,0 +1,41 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-ingress-websocket
+  labels:
+    apps: zotprime-ingress-websocket
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+  annotations:
+    cloud.google.com/load-balancer-type: External
+    kubernetes.io/ingress.class: gce
+#    cert-manager.io/issuer: letsencrypt-staging
+    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
+    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
+    nginx.ingress.kubernetes.io/rewrite-target: /
+    nginx.ingress.kubernetes.io/server-snippets: |
+      location / {
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_http_version 1.1;
+        proxy_set_header X-Forwarded-Host $http_host;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Forwarded-For $remote_addr;
+        proxy_set_header Host $host;
+        proxy_set_header Connection "upgrade";
+        proxy_cache_bypass $http_upgrade;
+      }
+spec:
+#  tls:
+#   - secretName: web-ssl
+#     hosts:
+#      - {{ .Values.ingressHostnames.streamserver }}
+  rules:
+  - host: {{ .Values.ingressHostnames.streamserver }}
+    http:
+      paths:
+      - backend:
+          service:
+            name: streamserver
+            port:
+              name: streamserver
+        path: /
+        pathType: Prefix
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/helm-chart/values.yaml b/zotprime-k8s/GKE/helm-chart/values.yaml
new file mode 100644
index 00000000..23c497c4
--- /dev/null
+++ b/zotprime-k8s/GKE/helm-chart/values.yaml
@@ -0,0 +1,320 @@
+ingressHostnames:
+  api: api.projectdev.net
+  streamserver: stream.projectdev.net
+  minios3Data: s3min.projectdev.net
+  phpmyadmin: pm.projectdev.net
+  minios3Web: min.projectdev.net
+zotprimeDataserver:
+  replicas: 1
+  zotprimeDataserver:
+    env:
+      dsuri: http://api.projectdev.net/
+      s3Pointuri: s3min.projectdev.net
+    image:
+      repository: uniuu/zotprime-dataserver
+      tag: 2.6.1-rc
+    imagePullPolicy: Always
+    resources:
+      limits:
+        memory: 512Mi
+      requests:
+        cpu: "1"
+        memory: 512Mi
+minioSecret:
+  secretTxt: "TUlOSU9fUk9PVF9QQVNTV09SRD16b3Rlcm9kb2NrZXIK"
+dbSecret:
+  secretTxt: "TUFSSUFEQl9ST09UX1BBU1NXT1JEPXpvdGVybwpNQVJJQURCX1BBU1NXT1JEPXpvdGVyb3Rlc3QK"
+dataserver:
+  ports:
+  - name: dataserver-api
+    port: 80
+    targetPort: 80
+  type: ClusterIP
+dataserverConfig:
+  initSh: |-
+    #!/bin/sh
+    set -eux
+    cd /var/www/zotero/misc && ./init-mysql.sh
+    cd /var/www/zotero/misc && ./db_update.sh
+    aws --endpoint-url "http://minio:9000" s3 mb s3://zotero
+    aws --endpoint-url "http://minio:9000" s3 mb s3://zotero-fulltext
+    aws --endpoint-url "http://localstack:4575" sns create-topic --name zotero
+dbConfig:
+  mariadbDatabasename: zoterotest
+  mariadbUser: zotero
+elasticsearch:
+  ports:
+  - name: elasticsearch
+    port: 9300
+    targetPort: 0
+  type: ClusterIP
+kubernetesClusterDomain: cluster.local
+localstack:
+  ports:
+  - name: localstack-sns
+    port: 4575
+    targetPort: sns-4575
+  - name: localstack-sqs
+    port: 4576
+    targetPort: sqs-4576
+  - name: localstack-apigateway
+    port: 4567
+    targetPort: apigateway-4567
+  type: ClusterIP
+memcached:
+  ports:
+  - name: memcached
+    port: 11211
+    targetPort: 11211
+  type: ClusterIP
+minio:
+  ports:
+  - name: minio-ui
+    port: 9001
+    targetPort: 9001
+  - name: minio-data
+    port: 9000
+    targetPort: 9000
+  - name: minio-data2
+    port: 80
+    targetPort: 9000
+  type: ClusterIP
+minioConfig:
+  minioUser: zotero
+mysql:
+  ports:
+  - name: mariadb
+    port: 3306
+    targetPort: 3306
+  type: ClusterIP
+phpmyadmin:
+  ports:
+  - name: phpmyadmin
+    port: 80
+    targetPort: 80
+  type: ClusterIP
+pvc:
+  dataminio:
+    storageClass: standard-rwo
+    storageRequest: 10Gi
+redis:
+  ports:
+  - name: redis
+    port: 6379
+    targetPort: 6379
+  type: ClusterIP
+redisConfig:
+  redisConf: |-
+    cluster-enabled no
+    appendonly yes
+    protected-mode no
+    dir /data
+    port 6379
+redisProbes:
+  livenessSh: |-
+    #!/bin/sh
+    pingResponse="$(redis-cli -h localhost ping | head -n1 | awk '{print $1;}')"
+    if [ "$?" -eq "124" ]; then
+      echo "PING timed out"
+      exit 1
+    fi
+    if [ "$pingResponse" != "PONG"] && [ "$pingResponse" != "LOADING" ] && [ "$pingResponse" != "MASTERDOWN" ]; then
+      echo "$pingResponse"
+      exit 1
+    fi
+  readinessSh: |-
+    #!/bin/sh
+    pingResponse="$(redis-cli -h localhost ping)"
+    if [ "$?" -eq "124" ]; then
+      echo "PING timed out"
+      exit 1
+    fi
+    if [ "$pingResponse" != "PONG"]; then
+      echo "$pingResponse"
+      exit 1
+    fi
+streamserver:
+  ports:
+  - name: streamserver
+    port: 81
+    targetPort: 81
+  type: ClusterIP
+tinymceclean:
+  ports:
+  - name: tinymceclean
+    port: 16342
+    targetPort: 16342
+  type: ClusterIP
+zotprimeDb:
+  mariadb:
+    args:
+    - set -o allexport && source tmp/_key/secret-db.txt && set +o allexport && /usr/local/bin/docker-entrypoint.sh
+      mysqld
+    containerSecurityContext:
+      runAsGroup: 999
+      runAsUser: 999
+    image:
+      repository: uniuu/zotprime-db
+      tag: 2.6.1-rc
+    resources:
+      limits:
+        memory: 512Mi
+      requests:
+        cpu: 500m
+        memory: 512Mi
+  replicas: 1
+  takeDataDirOwnership:
+    image:
+      repository: alpine
+      tag: "3"
+  volumeClaims:
+    datadb:
+      requests:
+        storage: 2Gi
+zotprimeElasticsearch:
+  elasticsearch:
+    env:
+      clusterName: zotero
+      clusterRoutingAllocationDiskThresholdEnabled: "false"
+      discoveryType: single-node
+      xpackSecurityEnabled: "true"
+    image:
+      repository: uniuu/zotprime-elasticsearch
+      tag: 2.6.1-rc
+    resources:
+      limits:
+        memory: 2Gi
+      requests:
+        cpu: "1"
+        memory: 2Gi
+  initSysctl:
+    containerSecurityContext:
+      privileged: true
+    image:
+      repository: busybox
+      tag: 1.27.2
+  replicas: 1
+  volumeClaims:
+    dataes:
+      requests:
+        storage: 2Gi
+zotprimeLocalstack:
+  localstack:
+    containerSecurityContext: {}
+    env:
+      debug: "0"
+      localstackK8SServiceName: localstack
+      services: sns,sqs,apigateway
+    image:
+      repository: uniuu/zotprime-localstack
+      tag: 2.6.1-rc
+    imagePullPolicy: IfNotPresent
+    resources:
+      limits:
+        memory: 256Mi
+      requests:
+        cpu: 200m
+        memory: 256Mi
+  replicas: 1
+zotprimeMemcached:
+  memcached:
+    args:
+    - -m 2047
+    - -I 50M
+    image:
+      repository: uniuu/zotprime-memcached
+      tag: 2.6.1-rc
+    resources:
+      limits:
+#        memory: 1536Mi
+        memory: 2Gi
+      requests:
+        cpu: 200m
+#        memory: 1536Mi
+        memory: 2Gi
+  replicas: 1
+zotprimeMinio:
+  replicas: 1
+  zotprimeMinio:
+    args:
+    - set -o allexport && source tmp/_key/secret-minio.txt && set +o allexport && minio
+      server /data --console-address :9001
+    image:
+      repository: uniuu/zotprime-minio
+      tag: 2.6.1-rc
+    imagePullPolicy: Always
+    resources:
+      limits:
+        memory: 512Mi
+      requests:
+        cpu: 500m
+        memory: 512Mi
+  zotprimeMiniomc:
+    image:
+      repository: uniuu/zotprime-miniomc
+      tag: 2.6.1-rc
+    resources:
+      limits:
+        memory: 256Mi
+      requests:
+        cpu: 200m
+        memory: 256Mi
+zotprimePhpmyadmin:
+  phpmyadmin:
+    env:
+      pmaHost: mysql
+      pmaPort: "3306"
+    image:
+      repository: uniuu/zotprime-phpmyadmin
+      tag: 2.6.1-rc
+    resources:
+      limits:
+        memory: 256Mi
+      requests:
+        cpu: 100m
+        memory: 256Mi
+zotprimeRedis:
+  redis:
+    args:
+    - /conf/redis.conf
+    - --protected-mode
+    - "no"
+    image:
+      repository: uniuu/zotprime-redis
+      tag: 2.6.1-rc
+    resources:
+      limits:
+        memory: 100Mi
+      requests:
+        cpu: 100m
+        memory: 100Mi
+  replicas: 1
+  volumeClaims:
+    dataredis:
+      requests:
+        storage: 1Gi
+zotprimeStreamserver:
+  replicas: 1
+  zotprimeStreamserver:
+    image:
+      repository: uniuu/zotprime-streamserver
+      tag: 2.6.1-rc
+    resources:
+      limits:
+        memory: 256Mi
+      requests:
+        cpu: "1"
+        memory: 256Mi
+zotprimeTinymceclean:
+  replicas: 1
+  zotprimeTinymceclean:
+    image:
+      repository: uniuu/zotprime-tinymceclean
+      tag: 2.6.1-rc
+    imagePullPolicy: Always
+    resources:
+      limits:
+        memory: 256Mi
+      requests:
+        cpu: 100m
+        memory: 256Mi
diff --git a/zotprime-k8s/GKE/issuer-lets-encrypt-staging.yaml b/zotprime-k8s/GKE/issuer-lets-encrypt-staging.yaml
new file mode 100644
index 00000000..131c2a33
--- /dev/null
+++ b/zotprime-k8s/GKE/issuer-lets-encrypt-staging.yaml
@@ -0,0 +1,15 @@
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+  name: letsencrypt-staging
+  namespace: zotprime
+spec:
+  acme:
+    server: https://acme-staging-v02.api.letsencrypt.org/directory
+    email: uniuuu@myyahoo.com # ❗ Replace this with your email address
+    privateKeySecretRef:
+      name: letsencrypt-staging
+    solvers:
+    - http01:
+        ingress:
+          name: web-ingress
diff --git a/zotprime-k8s/GKE/manifests/init/init.sh b/zotprime-k8s/GKE/manifests/init/init.sh
new file mode 100755
index 00000000..d782bd66
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/init/init.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+
+set -eux
+
+cd /var/www/zotero/misc && ./init-mysql.sh
+cd /var/www/zotero/misc && ./db_update.sh
+aws --endpoint-url "http://minio:9000" s3 mb s3://zotero
+aws --endpoint-url "http://minio:9000" s3 mb s3://zotero-fulltext
+aws --endpoint-url "http://localstack:4575" sns create-topic --name zotero
diff --git a/zotprime-k8s/GKE/manifests/secret-db.yaml_example b/zotprime-k8s/GKE/manifests/secret-db.yaml_example
new file mode 100644
index 00000000..51f4d168
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/secret-db.yaml_example
@@ -0,0 +1,8 @@
+apiVersion: v1
+data:
+  secret-db.txt: TUFSSUFEQl9ST09UX1BBU1NXT1JEPXpvdGVybwpNQVJJQURCX1BBU1NXT1JEPXpvdGVyb3Rlc3QK
+kind: Secret
+metadata:
+  name: db-secret
+  namespace: zotprime
+type: Opaque
diff --git a/zotprime-k8s/GKE/manifests/secret-minio.yaml_example b/zotprime-k8s/GKE/manifests/secret-minio.yaml_example
new file mode 100644
index 00000000..bf99622c
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/secret-minio.yaml_example
@@ -0,0 +1,8 @@
+apiVersion: v1
+data:
+  secret-minio.txt: TUlOSU9fUk9PVF9QQVNTV09SRD16b3Rlcm9kb2NrZXIK
+kind: Secret
+metadata:
+  name: minio-secret
+  namespace: zotprime
+type: Opaque
diff --git a/zotprime-k8s/GKE/manifests/zotprime-dataserver-configmap.yaml b/zotprime-k8s/GKE/manifests/zotprime-dataserver-configmap.yaml
new file mode 100644
index 00000000..75e86b7a
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-dataserver-configmap.yaml
@@ -0,0 +1,16 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: dataserver-config
+  namespace: zotprime
+data:
+  init.sh: |
+    #!/bin/sh
+
+    set -eux
+
+    cd /var/www/zotero/misc && ./init-mysql.sh
+    cd /var/www/zotero/misc && ./db_update.sh
+    aws --endpoint-url "http://minio:9000" s3 mb s3://zotero
+    aws --endpoint-url "http://minio:9000" s3 mb s3://zotero-fulltext
+    aws --endpoint-url "http://localstack:4575" sns create-topic --name zotero
diff --git a/zotprime-k8s/GKE/manifests/zotprime-dataserver-deployment.yaml b/zotprime-k8s/GKE/manifests/zotprime-dataserver-deployment.yaml
new file mode 100644
index 00000000..84e878b4
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-dataserver-deployment.yaml
@@ -0,0 +1,74 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    apps: zotprime-dataserver
+  name: zotprime-dataserver
+  namespace: zotprime
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-dataserver
+  strategy: {}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-dataserver
+      name: zotprime-dataserver
+    spec:
+      containers:
+        - image: uniuu/zotprime-dataserver:2.6.1-rc
+          imagePullPolicy: Always
+          name: zotprime-dataserver
+          ports:
+            - containerPort: 80
+#            - containerPort: 8082
+          env:
+            - name: DSURI
+              value: http://api.projectdev.net/
+            - name: S3POINTURI
+              value: s3min.projectdev.net
+          lifecycle:
+            postStart:
+              exec:
+                command: [ "/bin/sh", "-xc", 'sleep 120; bash /tmp/_conf/init.sh ; exit 0' ]
+          livenessProbe:
+            httpGet:
+              path: /
+              port: 80
+            initialDelaySeconds: 160
+            periodSeconds: 30
+#            timeoutSeconds: 5
+          readinessProbe:
+            failureThreshold: 3
+            initialDelaySeconds: 150
+            periodSeconds: 30
+            successThreshold: 1
+            timeoutSeconds: 1
+            httpGet:
+              path: /
+              port: 80
+          resources:
+            limits:
+              memory: 512Mi
+#              cpu: "0.3"
+            requests:
+              memory: 512Mi
+              cpu: "1"
+          volumeMounts:
+            - name: dataserver-config
+              mountPath: "/tmp/_conf"
+              readOnly: true
+      priorityClassName: low-priority
+      restartPolicy: Always
+      hostAliases:
+        - ip: "10.30.11.11"
+          hostnames:
+          - "s3min.projectdev.net"
+      securityContext: {}
+      volumes:
+        - name: dataserver-config
+          configMap:
+            name: dataserver-config
+status: {}
diff --git a/zotprime-k8s/GKE/manifests/zotprime-dataserver-service.yaml b/zotprime-k8s/GKE/manifests/zotprime-dataserver-service.yaml
new file mode 100644
index 00000000..46732d38
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-dataserver-service.yaml
@@ -0,0 +1,22 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: dataserver
+  name: dataserver
+  namespace: zotprime
+  annotations:
+    cloud.google.com/neg: '{"ingress": true}'
+spec:
+  ports:
+    - name: dataserver-api
+      port: 80
+      targetPort: 80
+#    - name: s3
+#      port: 8082
+#      targetPort: 8082
+  selector:
+    apps: zotprime-dataserver
+  type: ClusterIP
+status:
+  loadBalancer: {}
diff --git a/zotprime-k8s/GKE/manifests/zotprime-db-configmap.yaml b/zotprime-k8s/GKE/manifests/zotprime-db-configmap.yaml
new file mode 100644
index 00000000..2ea61ce3
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-db-configmap.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: db-config
+  namespace: zotprime
+data:
+  mariadb-user: zotero
+  mariadb-databasename: zoterotest
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-db-service.yaml b/zotprime-k8s/GKE/manifests/zotprime-db-service.yaml
new file mode 100644
index 00000000..e9309147
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-db-service.yaml
@@ -0,0 +1,18 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: mysql
+  namespace: zotprime
+  annotations:
+    service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
+  labels:
+    apps: mysql
+spec:
+  ports:
+  - port: 3306
+    targetPort: 3306
+    name: mariadb
+  selector:
+    apps: zotprime-db
+  clusterIP: None
+#  publishNotReadyAddresses: True
diff --git a/zotprime-k8s/GKE/manifests/zotprime-db-statefulset.yaml b/zotprime-k8s/GKE/manifests/zotprime-db-statefulset.yaml
new file mode 100644
index 00000000..e8387bfb
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-db-statefulset.yaml
@@ -0,0 +1,83 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: zotprime-db
+  namespace: zotprime
+  labels:
+    apps: zotprime-db
+spec:
+  serviceName: "db-service"
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-db
+  template:
+    metadata:
+      labels:
+        apps: zotprime-db
+    spec:
+      initContainers:
+      - name: take-data-dir-ownership
+        image: alpine:3
+        # Give `mysql` user permissions a mounted volume
+        # https://stackoverflow.com/a/51195446/4360433
+        command:
+        - chown
+        - -R
+        - 999:999
+        - /var/lib/mysql
+        volumeMounts:
+        - name: datadb
+          mountPath: /var/lib/mysql 
+      containers:
+      - name: mariadb
+        image: uniuu/zotprime-db:2.6.1-rc
+        ports:
+        - containerPort: 3306
+          name: mariadb-port
+        resources:
+          limits:
+            memory: 512Mi
+#            cpu: "1"
+#            cpu: "0.5"
+          requests:
+            memory: 512Mi
+            cpu: "0.5"
+        command:
+          - /bin/bash
+          - -c
+        args: 
+          -  set -o allexport && source tmp/_key/secret-db.txt && set +o allexport && /usr/local/bin/docker-entrypoint.sh mysqld         
+        env:
+        - name: MARIADB_USER
+          valueFrom:
+            configMapKeyRef:
+              name: db-config
+              key: mariadb-user
+        - name: MARIADB_DATABASE
+          valueFrom:
+            configMapKeyRef:
+              name: db-config
+              key: mariadb-databasename
+        volumeMounts:
+        - name: datadb
+          mountPath: /var/lib/mysql/
+        - name: db-secret
+          mountPath: tmp/_key/
+#          subPath: _key/
+        securityContext:
+          runAsUser: 999
+          runAsGroup: 999
+      volumes:
+        - name: db-secret
+          secret:
+            secretName: db-secret
+      priorityClassName: high-priority
+  volumeClaimTemplates:
+  - metadata:
+      name: datadb
+    spec:
+      accessModes: [ "ReadWriteOnce" ]
+      resources:
+        requests:
+          storage: 2Gi
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-elasticsearch-service.yaml b/zotprime-k8s/GKE/manifests/zotprime-elasticsearch-service.yaml
new file mode 100644
index 00000000..530f6061
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-elasticsearch-service.yaml
@@ -0,0 +1,15 @@
+
+apiVersion: v1
+kind: Service
+metadata:
+  name: elasticsearch
+  namespace: zotprime
+  labels:
+    apps: elasticsearch
+spec:
+  ports:
+  - port: 9300
+    name: elasticsearch
+  clusterIP: None
+  selector:
+    apps: zotprime-elasticsearch
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-elasticsearch-statefulset.yaml b/zotprime-k8s/GKE/manifests/zotprime-elasticsearch-statefulset.yaml
new file mode 100644
index 00000000..40381684
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-elasticsearch-statefulset.yaml
@@ -0,0 +1,98 @@
+
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: zotprime-elasticsearch
+  namespace: zotprime
+  labels:
+#    component: elasticsearch
+    apps: zotprime-elasticsearch
+spec:
+  selector:
+    matchLabels:
+      apps: zotprime-elasticsearch
+  serviceName: "elasticsearch-masterservice"
+  replicas: 1
+  template:
+    metadata:
+      labels:
+#        component: elasticsearch
+        apps: zotprime-elasticsearch
+    spec:
+      initContainers:
+      - name: init-sysctl
+        image: busybox:1.27.2
+        command:
+        - sysctl
+        - -w
+        - vm.max_map_count=262144
+        securityContext:
+          privileged: true
+      containers:
+      - name: elasticsearch
+        image: uniuu/zotprime-elasticsearch:2.6.1-rc
+        env:
+        - name: CLUSTER_NAME
+          value: zotero
+        - name: xpack.security.enabled
+          value: "true"
+        - name: cluster.routing.allocation.disk.threshold_enabled
+          value: "false"
+        - name: discovery.type
+          value: single-node
+        - name: NAMESPACE
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.namespace
+#        - name: NODE_NAME
+#          valueFrom:
+#            fieldRef:
+#              fieldPath: metadata.name
+#        - name: NUMBER_OF_MASTERS
+#          value: "2"
+#        - name: NODE_MASTER
+#          value: "true"
+#        - name: NODE_INGEST
+#          value: "false"
+#        - name: NODE_DATA
+#          value: "false"
+#        - name: HTTP_ENABLE
+#          value: "false"
+#        - name: ES_JAVA_OPTS
+#          value: -Xms256m -Xmx256m
+        - name: PROCESSORS
+          valueFrom:
+            resourceFieldRef:
+              resource: limits.cpu
+        resources:
+          limits:
+#            cpu: "0.5"
+#            cpu: "1"
+#            memory: 2Gi
+            memory: 2Gi
+          requests:
+            cpu: "1"
+#            cpu: "0.25"
+#            memory: 1500Mi
+            memory: 2Gi
+        ports:
+        - containerPort: 9300
+          name: transport
+#        livenessProbe:
+#          tcpSocket:
+#            port: transport
+#          initialDelaySeconds: 120
+#          periodSeconds: 10
+        volumeMounts:
+        - name: dataes
+          mountPath: /data
+      priorityClassName: high-priority
+  volumeClaimTemplates:
+  - metadata:
+      name: dataes
+    spec:
+#      storageClassName: standard
+      accessModes: [ ReadWriteOnce ]
+      resources:
+        requests:
+          storage: 2Gi
diff --git a/zotprime-k8s/GKE/manifests/zotprime-ingress-http.yaml b/zotprime-k8s/GKE/manifests/zotprime-ingress-http.yaml
new file mode 100644
index 00000000..03b04143
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-ingress-http.yaml
@@ -0,0 +1,57 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  labels:
+    apps: zotprime-ingress-http
+  name: zotprime-ingress-http
+  namespace: zotprime
+  annotations:
+    nginx.ingress.kubernetes.io/rewrite-target: /
+    cloud.google.com/load-balancer-type: "External"
+    kubernetes.io/ingress.class: "gce"
+#    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
+#    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
+spec:
+  rules:
+    - host: min.projectdev.net
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: minio
+                port:
+                  name: minio-ui
+    - host: s3min.projectdev.net
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: minio
+                port:
+                  name: minio-data
+    - host: pm.projectdev.net
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: phpmyadmin
+                port:
+#                  name: phpmyadmin
+                  number: 80
+    - host: api.projectdev.net
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: dataserver
+                port:
+                  name: dataserver-api
+#                  number: 8080
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-ingress-websocket.yaml b/zotprime-k8s/GKE/manifests/zotprime-ingress-websocket.yaml
new file mode 100644
index 00000000..b26b7053
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-ingress-websocket.yaml
@@ -0,0 +1,42 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  labels:
+    apps: zotprime-ingress-websocket
+  name: zotprime-ingress-websocket
+  namespace: zotprime
+  annotations:
+    nginx.ingress.kubernetes.io/rewrite-target: /
+    cloud.google.com/load-balancer-type: "External"
+    kubernetes.io/ingress.class: "gce"
+    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
+    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
+#    nginx.ingress.kubernetes.io/configuration-snippet: |
+#       proxy_http_version 1.1;
+#       proxy_set_header Upgrade "websocket";
+#       proxy_set_header Connection "Upgrade";
+    nginx.ingress.kubernetes.io/server-snippets: |
+      location / {
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_http_version 1.1;
+        proxy_set_header X-Forwarded-Host $http_host;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Forwarded-For $remote_addr;
+        proxy_set_header Host $host;
+        proxy_set_header Connection "upgrade";
+        proxy_cache_bypass $http_upgrade;
+      }
+spec:
+  rules:
+    - host: stream.projectdev.net
+      http:
+        paths:
+          - backend:
+              service:
+                name: streamserver
+                port:
+                  name: streamserver
+#                  number: 8081
+#            pathType: Exact
+            path: /
+            pathType: Prefix
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-localstack-deployment.yaml b/zotprime-k8s/GKE/manifests/zotprime-localstack-deployment.yaml
new file mode 100644
index 00000000..76d010a2
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-localstack-deployment.yaml
@@ -0,0 +1,245 @@
+---
+# Source: localstack/templates/deployment.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: zotprime-localstack
+  namespace: "zotprime"
+  labels:
+    apps: zotprime-localstack
+#  annotations:
+    
+spec:
+  replicas: 1
+  strategy:
+    type: RollingUpdate
+  selector:
+    matchLabels:
+      apps: zotprime-localstack
+      
+  template:
+    metadata:
+      labels:
+        apps: zotprime-localstack
+    spec:
+#      serviceAccountName: localstack
+      securityContext:
+        {}
+      containers:
+        - name: localstack
+          securityContext:
+            {}
+          image: uniuu/zotprime-localstack:2.6.1-rc
+          imagePullPolicy: IfNotPresent
+          ports:
+            - name: edge
+              containerPort: 4566
+              protocol: TCP
+            - name: "sns-4575"
+              containerPort: 4575
+              protocol: TCP
+            - name: "sqs-4576"
+              containerPort: 4576
+              protocol: TCP
+            - name: "apigateway-4567"
+              containerPort: 4567
+              protocol: TCP
+            - name: "ext-svc-4510"
+              containerPort: 4510
+              protocol: TCP
+            - name: "ext-svc-4511"
+              containerPort: 4511
+              protocol: TCP
+            - name: "ext-svc-4512"
+              containerPort: 4512
+              protocol: TCP
+            - name: "ext-svc-4513"
+              containerPort: 4513
+              protocol: TCP
+            - name: "ext-svc-4514"
+              containerPort: 4514
+              protocol: TCP
+            - name: "ext-svc-4515"
+              containerPort: 4515
+              protocol: TCP
+            - name: "ext-svc-4516"
+              containerPort: 4516
+              protocol: TCP
+            - name: "ext-svc-4517"
+              containerPort: 4517
+              protocol: TCP
+            - name: "ext-svc-4518"
+              containerPort: 4518
+              protocol: TCP
+            - name: "ext-svc-4519"
+              containerPort: 4519
+              protocol: TCP
+            - name: "ext-svc-4520"
+              containerPort: 4520
+              protocol: TCP
+            - name: "ext-svc-4521"
+              containerPort: 4521
+              protocol: TCP
+            - name: "ext-svc-4522"
+              containerPort: 4522
+              protocol: TCP
+            - name: "ext-svc-4523"
+              containerPort: 4523
+              protocol: TCP
+            - name: "ext-svc-4524"
+              containerPort: 4524
+              protocol: TCP
+            - name: "ext-svc-4525"
+              containerPort: 4525
+              protocol: TCP
+            - name: "ext-svc-4526"
+              containerPort: 4526
+              protocol: TCP
+            - name: "ext-svc-4527"
+              containerPort: 4527
+              protocol: TCP
+            - name: "ext-svc-4528"
+              containerPort: 4528
+              protocol: TCP
+            - name: "ext-svc-4529"
+              containerPort: 4529
+              protocol: TCP
+            - name: "ext-svc-4530"
+              containerPort: 4530
+              protocol: TCP
+            - name: "ext-svc-4531"
+              containerPort: 4531
+              protocol: TCP
+            - name: "ext-svc-4532"
+              containerPort: 4532
+              protocol: TCP
+            - name: "ext-svc-4533"
+              containerPort: 4533
+              protocol: TCP
+            - name: "ext-svc-4534"
+              containerPort: 4534
+              protocol: TCP
+            - name: "ext-svc-4535"
+              containerPort: 4535
+              protocol: TCP
+            - name: "ext-svc-4536"
+              containerPort: 4536
+              protocol: TCP
+            - name: "ext-svc-4537"
+              containerPort: 4537
+              protocol: TCP
+            - name: "ext-svc-4538"
+              containerPort: 4538
+              protocol: TCP
+            - name: "ext-svc-4539"
+              containerPort: 4539
+              protocol: TCP
+            - name: "ext-svc-4540"
+              containerPort: 4540
+              protocol: TCP
+            - name: "ext-svc-4541"
+              containerPort: 4541
+              protocol: TCP
+            - name: "ext-svc-4542"
+              containerPort: 4542
+              protocol: TCP
+            - name: "ext-svc-4543"
+              containerPort: 4543
+              protocol: TCP
+            - name: "ext-svc-4544"
+              containerPort: 4544
+              protocol: TCP
+            - name: "ext-svc-4545"
+              containerPort: 4545
+              protocol: TCP
+            - name: "ext-svc-4546"
+              containerPort: 4546
+              protocol: TCP
+            - name: "ext-svc-4547"
+              containerPort: 4547
+              protocol: TCP
+            - name: "ext-svc-4548"
+              containerPort: 4548
+              protocol: TCP
+            - name: "ext-svc-4549"
+              containerPort: 4549
+              protocol: TCP
+            - name: "ext-svc-4550"
+              containerPort: 4550
+              protocol: TCP
+            - name: "ext-svc-4551"
+              containerPort: 4551
+              protocol: TCP
+            - name: "ext-svc-4552"
+              containerPort: 4552
+              protocol: TCP
+            - name: "ext-svc-4553"
+              containerPort: 4553
+              protocol: TCP
+            - name: "ext-svc-4554"
+              containerPort: 4554
+              protocol: TCP
+            - name: "ext-svc-4555"
+              containerPort: 4555
+              protocol: TCP
+            - name: "ext-svc-4556"
+              containerPort: 4556
+              protocol: TCP
+            - name: "ext-svc-4557"
+              containerPort: 4557
+              protocol: TCP
+            - name: "ext-svc-4558"
+              containerPort: 4558
+              protocol: TCP
+            - name: "ext-svc-4559"
+              containerPort: 4559
+              protocol: TCP
+#          livenessProbe:
+#            failureThreshold: 3
+#            initialDelaySeconds: 0
+#            periodSeconds: 10
+#            successThreshold: 1
+#            timeoutSeconds: 1
+#            httpGet:
+#              path: /_localstack/health
+#              port: edge
+#          readinessProbe:
+#            failureThreshold: 3
+#            initialDelaySeconds: 0
+#            periodSeconds: 10
+#            successThreshold: 1
+#            timeoutSeconds: 1
+#            httpGet:
+#              path: /_localstack/health
+#              port: edge
+          resources:
+            limits:
+              memory: 256Mi
+#              cpu: "0.1"
+            requests:
+              memory: 256Mi
+              cpu: "0.2"
+          env:
+            - name: SERVICES
+              value: "sns,sqs,apigateway"
+            - name: DEBUG
+              value: "0"
+#            - name: EXTERNAL_SERVICE_PORTS_START
+#              value: "4510"
+#            - name: EXTERNAL_SERVICE_PORTS_END
+#              value: "4560"
+            - name: LOCALSTACK_K8S_SERVICE_NAME
+              value: localstack
+            - name: LOCALSTACK_K8S_NAMESPACE
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.namespace
+#            - name: LAMBDA_RUNTIME_EXECUTOR
+#              value: "docker"
+#            - name: LAMBDA_K8S_IMAGE_PREFIX
+#              value: "localstack/lambda-"
+#            - name: LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT
+#              value: "60"
+#            - name: OVERRIDE_IN_DOCKER
+#              value: "1"
+#      volumes:
diff --git a/zotprime-k8s/GKE/manifests/zotprime-localstack-service.yaml b/zotprime-k8s/GKE/manifests/zotprime-localstack-service.yaml
new file mode 100644
index 00000000..e247ed88
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-localstack-service.yaml
@@ -0,0 +1,27 @@
+---
+# Source: localstack/templates/service.yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: localstack
+  namespace: "zotprime"
+  labels:
+    apps: localstack
+#  annotations:
+    
+spec:
+#  type: NodePort
+#  externalTrafficPolicy: ""
+  ports:
+    - name: "localstack-sns"
+      port: 4575
+      targetPort: "sns-4575"
+#      nodePort: 31566
+    - name: "localstack-sqs"
+      port: 4576
+      targetPort: "sqs-4576"
+    - name: "localstack-apigateway"
+      port: 4567
+      targetPort: "apigateway-4567"
+  selector:
+    apps: zotprime-localstack
diff --git a/zotprime-k8s/GKE/manifests/zotprime-memcached-deployment.yaml b/zotprime-k8s/GKE/manifests/zotprime-memcached-deployment.yaml
new file mode 100644
index 00000000..4bbd3807
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-memcached-deployment.yaml
@@ -0,0 +1,40 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: zotprime-memcached
+  namespace: zotprime
+  labels:
+    apps: zotprime-memcached
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-memcached
+  template:
+    metadata:
+      name: memcached
+      labels:
+        apps: zotprime-memcached
+    spec:
+      containers:
+      - name: memcached
+        image: uniuu/zotprime-memcached:2.6.1-rc
+        resources:
+          limits:
+#            cpu: 200m
+            memory: 2000Mi
+          requests:
+            cpu: 200m
+            memory: 2000Mi
+        ports:
+          - containerPort: 11211
+            protocol: TCP
+        args:
+          - -m 2047
+          - -I 50M
+      priorityClassName: high-priority
+#      - name: memcached-exporter
+#        image: prom/memcached-exporter
+#        ports:
+#          - containerPort: 9150
+#            protocol: TCP
diff --git a/zotprime-k8s/GKE/manifests/zotprime-memcached-service.yaml b/zotprime-k8s/GKE/manifests/zotprime-memcached-service.yaml
new file mode 100644
index 00000000..5127d670
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-memcached-service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: memcached
+  name: memcached
+  namespace: zotprime
+spec:
+  ports:
+    - name: memcached
+      port: 11211
+      targetPort: 11211
+  selector:
+    apps: zotprime-memcached
+  type: ClusterIP
+status:
+  loadBalancer: {}
diff --git a/zotprime-k8s/GKE/manifests/zotprime-minio-configmap.yaml b/zotprime-k8s/GKE/manifests/zotprime-minio-configmap.yaml
new file mode 100644
index 00000000..fb07fb61
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-minio-configmap.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: minio-config
+  namespace: zotprime
+data:
+  minio-user: zotero
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-minio-deployment.yaml b/zotprime-k8s/GKE/manifests/zotprime-minio-deployment.yaml
new file mode 100644
index 00000000..2bc93fec
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-minio-deployment.yaml
@@ -0,0 +1,120 @@
+# The `spec.containers[0].args` contains the command run on the pod
+# The `/data` directory corresponds to the `spec.containers[0].volumeMounts[0].mountPath`
+# That mount path corresponds to a Kubernetes HostPath which binds `/data` to a local drive or volume on the worker node where the pod runs
+# 
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    apps: zotprime-minio
+  name: zotprime-minio
+  namespace: zotprime # Change this value to match the namespace metadata.name
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-minio
+  strategy: {}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-minio
+      name: zotprime-minio
+    spec:
+      containers:
+        - name: zotprime-minio
+          image: uniuu/zotprime-minio:2.6.1-rc
+          imagePullPolicy: Always
+          resources:
+            limits:
+              memory: 512Mi
+#              cpu: "0.5"
+            requests:
+              memory: 512Mi
+              cpu: "0.5"
+          command:
+            - /bin/bash
+            - -c
+          args: 
+            -  set -o allexport && source tmp/_key/secret-minio.txt && set +o allexport && minio server /data --console-address :9001 
+#| tee /data/test.log
+#    lifecycle:
+#      preStop:
+#        exec:
+#          command:
+#            - /bin/sh
+#            - -c
+#          args: ls -lha /tmp/_key/
+          livenessProbe:
+            httpGet:
+              path: /minio/health/live
+              port: 9000
+            initialDelaySeconds: 10
+            periodSeconds: 30
+            timeoutSeconds: 20
+          readinessProbe:
+            failureThreshold: 3
+            initialDelaySeconds: 0
+            periodSeconds: 20
+            successThreshold: 1
+            timeoutSeconds: 1
+            httpGet:
+              path: /minio/health/live
+              port: 9000
+          ports:
+            - containerPort: 9000
+              name: data
+            - containerPort: 9001
+              name: ui
+          env:
+            - name: MINIO_ROOT_USER
+              valueFrom:
+                 configMapKeyRef:
+                   name: minio-config
+                   key: minio-user
+          volumeMounts:
+            - name: s3data # Corresponds to the `spec.volumes` Persistent Volume
+              mountPath: /data
+            - name: minio-secret
+              mountPath: "/tmp/_key"
+#             subPath: secret.txt
+              readOnly: true
+        - name: zotprime-miniomc
+          image: uniuu/zotprime-miniomc:2.6.1-rc
+          resources:
+            limits:
+              memory: 256Mi
+#              cpu: "0.2"
+            requests:
+              memory: 256Mi
+              cpu: "0.2"
+          volumeMounts:
+            - name: s3data # Corresponds to the `spec.volumes` Persistent Volume
+              mountPath: /data
+            - name: minio-secret
+              mountPath: "/tmp/_key"
+              readOnly: true
+          env:
+            - name: MINIO_ROOT_USER
+              valueFrom:
+                configMapKeyRef:
+                  name: minio-config
+                  key: minio-user
+#      nodeSelector:
+#        kubernetes.io/hostname: localhost.localdomain # Specify a node label associated to the Worker Node on which you want to deploy the pod.
+#  terminationGracePeriodSeconds: 30
+      priorityClassName: high-priority
+      restartPolicy: Always
+      securityContext: {}
+      volumes:
+        - name: s3data
+          persistentVolumeClaim:
+            claimName: dataminio
+#        - name: bucketsstore
+#          hostPath: # MinIO generally recommends using locally-attached volumes
+#            path: /mnt/disk1/data # Specify a path to a local drive or volume on the Kubernetes worker node
+#            type: DirectoryOrCreate # The path to the last directory must exist
+        - name: minio-secret
+          secret:
+            secretName: minio-secret
+status: {}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-minio-persistentvolumeclaim.yaml b/zotprime-k8s/GKE/manifests/zotprime-minio-persistentvolumeclaim.yaml
new file mode 100644
index 00000000..8ceb03b7
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-minio-persistentvolumeclaim.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: dataminio
+  namespace: zotprime
+spec:
+  accessModes:
+    - ReadWriteOnce
+  resources:
+    requests:
+      storage: 10Gi
+  storageClassName: standard-rwo
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-minio-service.yaml b/zotprime-k8s/GKE/manifests/zotprime-minio-service.yaml
new file mode 100644
index 00000000..05bca4bc
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-minio-service.yaml
@@ -0,0 +1,26 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: minio
+  name: minio
+  namespace: zotprime
+  annotations:
+    cloud.google.com/neg: '{"ingress": true}'
+spec:
+  clusterIP: 10.30.11.11
+  ports:
+    - name: minio-ui
+      port: 9001
+      targetPort: 9001
+    - name: minio-data
+      port: 9000
+      targetPort: 9000
+    - name: minio-data2
+      port: 80
+      targetPort: 9000
+  selector:
+    apps: zotprime-minio
+  type: ClusterIP
+status:
+  loadBalancer: {}
diff --git a/zotprime-k8s/GKE/manifests/zotprime-phpmyadmin-deployment.yaml b/zotprime-k8s/GKE/manifests/zotprime-phpmyadmin-deployment.yaml
new file mode 100644
index 00000000..554b2bea
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-phpmyadmin-deployment.yaml
@@ -0,0 +1,44 @@
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: zotprime-phpmyadmin
+  namespace: zotprime
+  labels:
+    apps: zotprime-phpmyadmin
+spec:
+  selector:
+    matchLabels:
+      apps: zotprime-phpmyadmin
+  template:
+    metadata:
+      labels:
+        apps: zotprime-phpmyadmin
+    spec:
+      containers:
+        - name: phpmyadmin
+          image: uniuu/zotprime-phpmyadmin:2.6.1-rc
+          ports:
+            - containerPort: 80
+          resources:
+            limits:
+              memory: 256Mi
+#              cpu: "0.1"
+            requests:
+              memory: 256Mi
+              cpu: "0.1"
+          env:
+            - name: PMA_HOST
+              value: mysql
+            - name: PMA_PORT
+              value: "3306"
+#            - name: PMA_ARBITRARY
+#              value: "1"
+#        - name: PMA_ABSOLUTE_URI
+#          value: [uri]
+
+#        - name: MYSQL_ROOT_PASSWORD
+#          valueFrom:
+#            secretKeyRef:
+#              name: mysql-secret
+#              key: root_password
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-phpmyadmin-service.yaml b/zotprime-k8s/GKE/manifests/zotprime-phpmyadmin-service.yaml
new file mode 100644
index 00000000..61b49555
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-phpmyadmin-service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: phpmyadmin
+  name: phpmyadmin
+  namespace: zotprime
+  annotations:
+    cloud.google.com/neg: '{"ingress": true}'
+spec:
+  ports:
+    - name: phpmyadmin
+      port: 80
+      targetPort: 80
+  selector:
+    apps: zotprime-phpmyadmin
+  type: ClusterIP
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-priorityclass-high.yaml b/zotprime-k8s/GKE/manifests/zotprime-priorityclass-high.yaml
new file mode 100644
index 00000000..2fc9e7da
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-priorityclass-high.yaml
@@ -0,0 +1,8 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: high-priority
+  namespace: zotprime
+value: 1000000 
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-priorityclass-low.yaml b/zotprime-k8s/GKE/manifests/zotprime-priorityclass-low.yaml
new file mode 100644
index 00000000..81e69741
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-priorityclass-low.yaml
@@ -0,0 +1,8 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: low-priority
+  namespace: zotprime
+value: 800000 
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-priorityclass-medium.yaml b/zotprime-k8s/GKE/manifests/zotprime-priorityclass-medium.yaml
new file mode 100644
index 00000000..f12dad40
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-priorityclass-medium.yaml
@@ -0,0 +1,8 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: medium-priority
+  namespace: zotprime
+value: 900000 
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-redis-configmap.yaml b/zotprime-k8s/GKE/manifests/zotprime-redis-configmap.yaml
new file mode 100644
index 00000000..44bd7c59
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-redis-configmap.yaml
@@ -0,0 +1,44 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: redis-config
+  namespace: zotprime
+data:
+  redis.conf:  |+
+    cluster-enabled no
+    appendonly yes
+    protected-mode no
+    dir /data
+    port 6379
+
+# cluster-node-timeout 15000
+# cluster-config-file /data/nodes.conf
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: redis-probes
+  namespace: zotprime
+data:
+  readiness.sh: |-
+    #!/bin/sh
+    pingResponse="$(redis-cli -h localhost ping)"
+    if [ "$?" -eq "124" ]; then
+      echo "PING timed out"
+      exit 1
+    fi
+    if [ "$pingResponse" != "PONG"]; then
+      echo "$pingResponse"
+      exit 1
+    fi
+  liveness.sh: |-
+    #!/bin/sh
+    pingResponse="$(redis-cli -h localhost ping | head -n1 | awk '{print $1;}')"
+    if [ "$?" -eq "124" ]; then
+      echo "PING timed out"
+      exit 1
+    fi
+    if [ "$pingResponse" != "PONG"] && [ "$pingResponse" != "LOADING" ] && [ "$pingResponse" != "MASTERDOWN" ]; then
+      echo "$pingResponse"
+      exit 1
+    fi
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-redis-service.yaml b/zotprime-k8s/GKE/manifests/zotprime-redis-service.yaml
new file mode 100644
index 00000000..87b6ef70
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-redis-service.yaml
@@ -0,0 +1,18 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: redis
+  namespace: zotprime
+#  annotations:
+#    service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
+  labels:
+    apps: redis
+spec:
+  ports:
+  - port: 6379
+    targetPort: 6379
+    name: redis
+  selector:
+    apps: zotprime-redis
+  clusterIP: None
+#  publishNotReadyAddresses: True
diff --git a/zotprime-k8s/GKE/manifests/zotprime-redis-statefulset.yaml b/zotprime-k8s/GKE/manifests/zotprime-redis-statefulset.yaml
new file mode 100644
index 00000000..4527165b
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-redis-statefulset.yaml
@@ -0,0 +1,104 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: zotprime-redis
+  namespace: zotprime
+  labels:
+    apps: zotprime-redis
+spec:
+  serviceName: "redis-service"
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-redis
+  template:
+    metadata:
+      labels:
+        apps: zotprime-redis
+#        appCluster: redis-cluster
+    spec:
+      terminationGracePeriodSeconds: 20
+      affinity:
+        podAntiAffinity:
+          preferredDuringSchedulingIgnoredDuringExecution:
+          - weight: 100
+            podAffinityTerm:
+              labelSelector:
+                matchExpressions:
+                - key: app
+                  operator: In
+                  values:
+                  - redis
+              topologyKey: kubernetes.io/hostname
+      containers:
+      - name: redis
+        image: uniuu/zotprime-redis:2.6.1-rc
+        command:
+          - "redis-server"
+        args:
+          - "/conf/redis.conf"
+          - "--protected-mode"
+          - "no"
+        resources:
+          requests:
+            cpu: "100m"
+            memory: "100Mi"
+          limits:
+#            cpu: "100m"
+            memory: "100Mi"
+        ports:
+            - name: redis
+              containerPort: 6379
+              protocol: "TCP"
+            - name: cluster
+              containerPort: 16379
+              protocol: "TCP"
+        startupProbe:
+          periodSeconds: 5
+          timeoutSeconds: 5
+          successThreshold: 1
+          failureThreshold: 20
+          tcpSocket:
+            port: redis
+        livenessProbe:
+          periodSeconds: 5
+          timeoutSeconds: 5
+          successThreshold: 1
+          failureThreshold: 5
+          exec:
+            command: ["sh", "-c", "/probes/liveness.sh"]
+        readinessProbe:
+          periodSeconds: 5
+          timeoutSeconds: 1
+          successThreshold: 1
+          failureThreshold: 5
+          exec:
+            command: ["sh", "-c", "/probes/readiness.sh"]
+        volumeMounts:
+        - name: conf
+          mountPath: /conf
+          readOnly: false
+        - name: dataredis
+          mountPath: /data
+          readOnly: false
+        - name: probes
+          mountPath: /probes
+          readOnly: true
+      priorityClassName: high-priority
+      volumes:
+      - name: conf
+        configMap:
+          name: redis-config
+          defaultMode: 0755
+      - name: probes
+        configMap:
+          name: redis-probes
+          defaultMode: 0555
+  volumeClaimTemplates:
+  - metadata:
+      name: dataredis
+    spec:
+      accessModes: [ "ReadWriteOnce" ]
+      resources:
+        requests:
+          storage: 1Gi
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-streamserver-backendconfig.yaml b/zotprime-k8s/GKE/manifests/zotprime-streamserver-backendconfig.yaml
new file mode 100644
index 00000000..9b8b9995
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-streamserver-backendconfig.yaml
@@ -0,0 +1,15 @@
+apiVersion: cloud.google.com/v1
+kind: BackendConfig
+metadata:
+  name: streamserver-backendconfig
+  namespace: zotprime
+spec:
+  timeoutSec: 3600
+#  healthCheck:
+#    checkIntervalSec: 30
+#    timeoutSec: 20
+#    healthyThreshold: 1
+#    unhealthyThreshold: 3
+#    type: HTTP
+#    requestPath: /health
+#    port: 81
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/manifests/zotprime-streamserver-deployment.yaml b/zotprime-k8s/GKE/manifests/zotprime-streamserver-deployment.yaml
new file mode 100644
index 00000000..d4d04abc
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-streamserver-deployment.yaml
@@ -0,0 +1,51 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    apps: zotprime-streamserver
+  name: zotprime-streamserver
+  namespace: zotprime
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-streamserver
+  strategy: {}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-streamserver
+      name: zotprime-streamserver
+    spec:
+      containers:
+        - image: uniuu/zotprime-streamserver:2.6.1-rc
+          name: zotprime-streamserver
+          ports:
+            - containerPort: 81           
+          resources:
+            limits:
+              memory: 256Mi
+#              cpu: "0.5"
+            requests:
+              memory: 256Mi
+              cpu: "1"
+          livenessProbe:
+            httpGet:
+              path: /health
+              port: 81
+            initialDelaySeconds: 10
+            periodSeconds: 30
+            timeoutSeconds: 20
+          readinessProbe:
+            failureThreshold: 3
+            initialDelaySeconds: 0
+            periodSeconds: 20
+            successThreshold: 1
+            timeoutSeconds: 1
+            httpGet:
+              path: /health
+              port: 81
+      priorityClassName: medium-priority      
+      restartPolicy: Always
+      securityContext: {}
+status: {}
diff --git a/zotprime-k8s/GKE/manifests/zotprime-streamserver-service.yaml b/zotprime-k8s/GKE/manifests/zotprime-streamserver-service.yaml
new file mode 100644
index 00000000..c973b12b
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-streamserver-service.yaml
@@ -0,0 +1,22 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: streamserver
+  name: streamserver
+  namespace: zotprime
+  annotations:
+    cloud.google.com/neg: '{"ingress": true}'
+#    beta.cloud.google.com/backend-config: '{"ports": {"81": "config-broker-ws"}}'
+    cloud.google.com/backend-config: '{"default": "streamserver-backendconfig"}'
+spec:
+  ports:
+    - name: streamserver
+      port: 81
+      targetPort: 81
+  selector:
+    apps: zotprime-streamserver
+  type: ClusterIP
+#status:
+#  loadBalancer: {}
+
diff --git a/zotprime-k8s/GKE/manifests/zotprime-tinymceclean-deployment.yaml b/zotprime-k8s/GKE/manifests/zotprime-tinymceclean-deployment.yaml
new file mode 100644
index 00000000..6cd05d20
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-tinymceclean-deployment.yaml
@@ -0,0 +1,36 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    apps: zotprime-tinymceclean
+  name: zotprime-tinymceclean
+  namespace: zotprime
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-tinymceclean
+  strategy: {}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-tinymceclean
+      name: zotprime-tinymceclean
+    spec:
+      containers:
+        - image: uniuu/zotprime-tinymceclean:2.6.1-rc
+          imagePullPolicy: Always
+          name: zotprime-tinymceclean
+          ports:
+            - containerPort: 16342
+          resources:
+            limits:
+              memory: 256Mi
+#              cpu: "0.1"
+            requests:
+              memory: 256Mi
+              cpu: "0.1"
+      priorityClassName: medium-priority
+      restartPolicy: Always
+      securityContext: {}
+status: {}
diff --git a/zotprime-k8s/GKE/manifests/zotprime-tinymceclean-service.yaml b/zotprime-k8s/GKE/manifests/zotprime-tinymceclean-service.yaml
new file mode 100644
index 00000000..b4eeaac7
--- /dev/null
+++ b/zotprime-k8s/GKE/manifests/zotprime-tinymceclean-service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: tinymceclean
+  name: tinymceclean
+  namespace: zotprime
+spec:
+  ports:
+    - name: tinymceclean
+      port: 16342
+      targetPort: 16342
+  selector:
+    apps: zotprime-tinymceclean
+  type: ClusterIP
+#status:
+#  loadBalancer: {}
diff --git a/zotprime-k8s/GKE/secret/secret-db.txt_example b/zotprime-k8s/GKE/secret/secret-db.txt_example
new file mode 100644
index 00000000..a6773e99
--- /dev/null
+++ b/zotprime-k8s/GKE/secret/secret-db.txt_example
@@ -0,0 +1,2 @@
+MARIADB_ROOT_PASSWORD=zotero
+MARIADB_PASSWORD=zoterotest
diff --git a/zotprime-k8s/GKE/secret/secret-minio.txt_example b/zotprime-k8s/GKE/secret/secret-minio.txt_example
new file mode 100644
index 00000000..96006eed
--- /dev/null
+++ b/zotprime-k8s/GKE/secret/secret-minio.txt_example
@@ -0,0 +1 @@
+MINIO_ROOT_PASSWORD=zoterodocker
diff --git a/zotprime-k8s/GKE/terraform/.gitignore b/zotprime-k8s/GKE/terraform/.gitignore
new file mode 100644
index 00000000..7e800baf
--- /dev/null
+++ b/zotprime-k8s/GKE/terraform/.gitignore
@@ -0,0 +1,37 @@
+.pluralith/*
+**/kubeconfig-*
+
+# Local .terraform directories
+**/.terraform/*
+
+# .tfstate files
+*.tfstate
+*.tfstate.*
+
+# Crash log files
+crash.log
+crash.*.log
+
+# Exclude all .tfvars files, which are likely to contain sensitive data, such as
+# password, private keys, and other secrets. These should not be part of version 
+# control as they are data points which are potentially sensitive and subject 
+# to change depending on the environment.
+*.tfvars
+*.tfvars.json
+
+# Ignore override files as they are usually used to override resources locally and so
+# are not checked in
+override.tf
+override.tf.json
+*_override.tf
+*_override.tf.json
+
+# Include override files you do wish to add to version control using negated pattern
+# !example_override.tf
+
+# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
+# example: *tfplan*
+
+# Ignore CLI configuration files
+.terraformrc
+terraform.rc
diff --git a/zotprime-k8s/GKE/terraform/.terraform.lock.hcl b/zotprime-k8s/GKE/terraform/.terraform.lock.hcl
new file mode 100644
index 00000000..fa62f6d1
--- /dev/null
+++ b/zotprime-k8s/GKE/terraform/.terraform.lock.hcl
@@ -0,0 +1,101 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/google" {
+  version     = "4.66.0"
+  constraints = ">= 3.33.0, >= 3.45.0, >= 3.83.0, >= 4.25.0, >= 4.47.0, != 4.49.0, != 4.50.0, >= 4.51.0, != 4.65.0, != 4.65.1, >= 4.66.0, < 5.0.0"
+  hashes = [
+    "h1:ykmsArGX1/JTEbqMMUXA9s1H+IdtXnKanl5dh4YsaXo=",
+    "zh:141cddc714dec246957a47cb4103b34302222fc93a87b64de88116b22ebb0ea1",
+    "zh:276ebd75cb7c265d12b2c611a5f8d38fd6b892ef3edec1b845a934721db794e5",
+    "zh:574ae7b4808c1560b5a55a75ca2ad5d8ff6b5fb9dad6dffce3fae7ff8ccf78a9",
+    "zh:65309953f79827c23cc800fc093619a1e0e51a53e2429e9b04e537a11012f989",
+    "zh:6d67d3edea47767a873c38f1ff519d4450d8e1189a971bda7b0ffde9c9c65a86",
+    "zh:7fb116be869e30ee155c27f122d415f34d1d5de735d1fa9c4280cac71a42e8f4",
+    "zh:8a95ed92bb4547f4a40c953a6bd1db659b739f67adcacd798b11fafaec55ee67",
+    "zh:94f0179e84eb74823d8be4781b0a15f7f34ee39a7b158075504c882459f1ab23",
+    "zh:a58a7c5ace957cb4395f4b3bb11687e3a5c79362a744107f16623118cffc9370",
+    "zh:ab38b66f3c5c00df64c86fb4e47caef8cf451d5ed1f76845fd8b2c59628dc18a",
+    "zh:cc6bb1799e38912affc2a5b6f1c52b08f286d3751206532c04482b5ca0418eb6",
+    "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/google-beta" {
+  version     = "4.66.0"
+  constraints = ">= 3.45.0, < 5.0.0"
+  hashes = [
+    "h1:XtqlwIAHBxwgKaqZ3OuoHzqPImGRiXjZHpm8ur9xSJ4=",
+    "zh:253391f3b3cc9c6908b9fcbb8704e423071121fef476d5de824a187df76924a0",
+    "zh:2fb223b4fba1fcccb02cc3d0d5103fdf687a722b461828b3885043dd643f8efd",
+    "zh:6ca0094c20f4e9f25f11ab016f0b54fcfd62076ea30bb43d4c69d52633a0cfb8",
+    "zh:757ffff89a521073c8fa7f663cf3d9d20629d6e72b837b74c0221bcf34531cfd",
+    "zh:7d1459b9b3bd9e0dc887b9c476cfa58e2cbb7d56d5ffdeaec0fdd535a38373d4",
+    "zh:92ad7a5489cd3f51b69c0136095d94f3092c8c7e0d5c8befe1ff53c18761aade",
+    "zh:9f477e3dbaac8302160bfcfb9c064de72eb6776130a5671380066ac2e84ceae8",
+    "zh:d1580b146b16d56ccd18a1bbc4a4cac2607e37ed5baf6290cc929f5c025bf526",
+    "zh:d30d5b3ebd6c4123a53cef4c7c6606b06d27f1cb798b387b9a65b55f8c7b6b9f",
+    "zh:e3cdc92f111499702f7a807fe6cf8873714939efc05b774cfbde76b8a199da46",
+    "zh:f2cd44444b6d7760a8a6deaf54ca67ae3696d3f5640b107ad7be91dde8a60c25",
+    "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/kubernetes" {
+  version     = "2.20.0"
+  constraints = "~> 2.10"
+  hashes = [
+    "h1:Xe79v44FUVUxxZz8GQ9puZoW+AsNGyJmTB58RG9cjkw=",
+    "zh:30bc224c94d2c90a7d44554f2ad30e3b62c7ffc6ddb7d4fd31b9acafb8b5ad77",
+    "zh:3903cc9f0c3169a24265c4920d925ed7e37cbc4312237b29bd5b4ddcd6bdc535",
+    "zh:512240f6dad36c0116a8717487a4ea12a6b4191028782c5b6749037892e2c6ed",
+    "zh:57d5f77dcde7781803b465205aec3507780bfaa77031f5b893ae7cbebd4789b6",
+    "zh:6274ab8c3b59634c344c337218223640e9d954996b9299587ca924e4dfb77aa4",
+    "zh:6d838a25f3e3c696cf894f0adb44b41b461a2c76f914f1ae2c318ccbb1ec4e36",
+    "zh:92f09e3e03311c4e24601b704d85de57677f49e29f42cc3479fafa68f5de300a",
+    "zh:abb3cd606e485a46c076d6f60d37b5e5ecaa128c0150c8235627b484f2fac902",
+    "zh:afc07f5c0d7ce2cc907600e4f87a1290203a36221951e19e5d3f1409a0502377",
+    "zh:d9c01e4f12fabf5d6d9d11ceb409585b71c2abcad478496446de6ff18bbf2f5f",
+    "zh:f40faba2269184b305f229503945400ed6eeafec7ac395c23f243bccab7b11b2",
+    "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/local" {
+  version = "2.4.0"
+  hashes = [
+    "h1:R97FTYETo88sT2VHfMgkPU3lzCsZLunPftjSI5vfKe8=",
+    "zh:53604cd29cb92538668fe09565c739358dc53ca56f9f11312b9d7de81e48fab9",
+    "zh:66a46e9c508716a1c98efbf793092f03d50049fa4a83cd6b2251e9a06aca2acf",
+    "zh:70a6f6a852dd83768d0778ce9817d81d4b3f073fab8fa570bff92dcb0824f732",
+    "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+    "zh:82a803f2f484c8b766e2e9c32343e9c89b91997b9f8d2697f9f3837f62926b35",
+    "zh:9708a4e40d6cc4b8afd1352e5186e6e1502f6ae599867c120967aebe9d90ed04",
+    "zh:973f65ce0d67c585f4ec250c1e634c9b22d9c4288b484ee2a871d7fa1e317406",
+    "zh:c8fa0f98f9316e4cfef082aa9b785ba16e36ff754d6aba8b456dab9500e671c6",
+    "zh:cfa5342a5f5188b20db246c73ac823918c189468e1382cb3c48a9c0c08fc5bf7",
+    "zh:e0e2b477c7e899c63b06b38cd8684a893d834d6d0b5e9b033cedc06dd7ffe9e2",
+    "zh:f62d7d05ea1ee566f732505200ab38d94315a4add27947a60afa29860822d3fc",
+    "zh:fa7ce69dde358e172bd719014ad637634bbdabc49363104f4fca759b4b73f2ce",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/random" {
+  version     = "3.5.1"
+  constraints = ">= 2.1.0"
+  hashes = [
+    "h1:VSnd9ZIPyfKHOObuQCaKfnjIHRtR7qTw19Rz8tJxm+k=",
+    "zh:04e3fbd610cb52c1017d282531364b9c53ef72b6bc533acb2a90671957324a64",
+    "zh:119197103301ebaf7efb91df8f0b6e0dd31e6ff943d231af35ee1831c599188d",
+    "zh:4d2b219d09abf3b1bb4df93d399ed156cadd61f44ad3baf5cf2954df2fba0831",
+    "zh:6130bdde527587bbe2dcaa7150363e96dbc5250ea20154176d82bc69df5d4ce3",
+    "zh:6cc326cd4000f724d3086ee05587e7710f032f94fc9af35e96a386a1c6f2214f",
+    "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+    "zh:b6d88e1d28cf2dfa24e9fdcc3efc77adcdc1c3c3b5c7ce503a423efbdd6de57b",
+    "zh:ba74c592622ecbcef9dc2a4d81ed321c4e44cddf7da799faa324da9bf52a22b2",
+    "zh:c7c5cde98fe4ef1143bd1b3ec5dc04baf0d4cc3ca2c5c7d40d17c0e9b2076865",
+    "zh:dac4bad52c940cd0dfc27893507c1e92393846b024c5a9db159a93c534a3da03",
+    "zh:de8febe2a2acd9ac454b844a4106ed295ae9520ef54dc8ed2faf29f12716b602",
+    "zh:eab0d0495e7e711cca367f7d4df6e322e6c562fc52151ec931176115b83ed014",
+  ]
+}
diff --git a/zotprime-k8s/GKE/terraform/main.tf b/zotprime-k8s/GKE/terraform/main.tf
new file mode 100644
index 00000000..8b451acb
--- /dev/null
+++ b/zotprime-k8s/GKE/terraform/main.tf
@@ -0,0 +1,130 @@
+provider "google" {
+  credentials = file("auth/cred.json")
+}
+
+provider "google-beta" {
+  credentials = file("auth/cred.json")
+}
+terraform {
+  required_providers {
+    google = {
+      source  = "hashicorp/google"
+      version = ">= 4.66.0"
+    }
+
+    google-beta = {
+      source  = "hashicorp/google-beta"
+      version = ">= 4.66.0"
+    }
+
+    /*  kubectl = {
+      source  = "gavinbunney/kubectl"
+      version = ">= 1.14.0"
+    } */
+  }
+
+  required_version = ">= 1.4.6"
+}
+
+module "gke_auth" {
+  source       = "terraform-google-modules/kubernetes-engine/google//modules/auth"
+  version      = "26.1.1"
+  depends_on   = [module.k8s]
+  project_id   = var.project_id
+  location     = module.k8s.location
+  cluster_name = module.k8s.name
+}
+
+module "vpc" {
+  source       = "terraform-google-modules/network/google"
+  project_id   = var.project_id
+  network_name = "${var.network}-${var.env_name}"
+  version      = "~> 7.0.0"
+  #  version      = "6.0.0"
+  subnets = [
+    {
+      subnet_name   = "${var.subnetwork}-${var.env_name}"
+      subnet_ip     = "10.10.0.0/16"
+      subnet_region = var.region
+    },
+  ]
+  secondary_ranges = {
+    "${var.subnetwork}-${var.env_name}" = [
+      {
+        range_name    = var.ip_range_pods_name
+        ip_cidr_range = "10.20.0.0/16"
+      },
+      {
+        range_name    = var.ip_range_services_name
+        ip_cidr_range = "10.30.0.0/16"
+      },
+    ]
+  }
+}
+
+module "k8s" {
+  source                   = "terraform-google-modules/kubernetes-engine/google//modules/private-cluster"
+  version                  = "26.1.1"
+  project_id               = var.project_id
+  name                     = "${var.cluster_name}-${var.env_name}"
+  regional                 = false
+  region                   = var.region
+  zones                    = var.zones
+  network                  = module.vpc.network_name
+  subnetwork               = module.vpc.subnets_names[0]
+  ip_range_pods            = var.ip_range_pods_name
+  ip_range_services        = var.ip_range_services_name
+  remove_default_node_pool = true
+  initial_node_count       = 1
+  gce_pd_csi_driver        = true
+
+  create_service_account = false
+
+  #  network_policy             = false
+  #  horizontal_pod_autoscaling = true
+  #  http_load_balancing        = true
+
+  node_pools = [
+    {
+      name         = "nodepool"
+      machine_type = var.machine
+      #node_locations = "asia-southeast1-a,asia-southeast1-b,asia-southeast1-c"
+      node_locations = var.node-locations # node_locations Optional. The list of zones in which the cluster's nodes are located. Nodes must be in the region of their regional cluster or in the same region as their cluster's zone for zonal clusters. Defaults to cluster level node locations if nothing is specified.
+      min_count      = var.minnode
+      max_count      = var.maxnode
+      disk_size_gb   = var.disksize
+      preemptible    = false
+      auto_repair    = false
+      auto_upgrade   = true
+    },
+  ]
+  cluster_resource_labels    = { "env" = "${var.env_name}" }
+  node_pools_labels          = { nodepool = { "env" = "${var.env_name}" } }
+  node_pools_resource_labels = { nodepool = { "env" = "${var.env_name}" } }
+  node_pools_tags            = { nodepool = ["zotprime"] }
+}
+
+
+resource "local_file" "kubeconfig" {
+  content  = module.gke_auth.kubeconfig_raw
+  filename = "kubeconfig-${var.env_name}"
+}
+
+
+#provider "kubectl" {
+#  load_config_file = false
+#  host             = "https://module.k8s.endpoint"
+#  token            = module.gke_auth.token
+#  #cluster_ca_certificate = base64decode(module.gke_auth.cluster_ca_certificate)
+#  cluster_ca_certificate = base64decode(module.k8s.ca_certificate)
+#}
+
+#data "kubectl_filename_list" "manifests" {
+#  pattern = "../manifests/*.yaml"
+#}
+
+#resource "kubectl_manifest" "zotprime" {
+#  count     = length(data.kubectl_filename_list.manifests.matches)
+#  yaml_body = file(element(data.kubectl_filename_list.manifests.matches, count.index))
+#}
+
diff --git a/zotprime-k8s/GKE/terraform/objects b/zotprime-k8s/GKE/terraform/objects
new file mode 100644
index 00000000..a5cddc93
--- /dev/null
+++ b/zotprime-k8s/GKE/terraform/objects
@@ -0,0 +1,120 @@
+displayName: cloudtrace.googleapis.com
+location: global
+name: //serviceusage.googleapis.com/projects/414581404162/services/cloudtrace.googleapis.com
+parentAssetType: cloudresourcemanager.googleapis.com/Project
+parentFullResourceName: //cloudresourcemanager.googleapis.com/projects/clever-span-387012
+project: projects/414581404162
+state: ENABLED
+---
+assetType: servicedirectory.googleapis.com/Service
+createTime: '2023-05-23T03:16:25Z'
+displayName: gk3-zotprime-k8s-dev-7cfbec75-b20aaa5f-pe
+location: asia-southeast1
+name: //servicedirectory.googleapis.com/projects/clever-span-387012/locations/asia-southeast1/namespaces/goog-psc-default/services/gk3-zotprime-k8s-dev-7cfbec75-b20aaa5f-pe
+parentAssetType: servicedirectory.googleapis.com/Namespace
+parentFullResourceName: //servicedirectory.googleapis.com/projects/clever-span-387012/locations/asia-southeast1/namespaces/goog-psc-default
+project: projects/414581404162
+updateTime: '2023-05-23T03:16:25Z'
+---
+assetType: servicedirectory.googleapis.com/Endpoint
+createTime: '2023-05-23T03:16:25Z'
+displayName: default
+location: asia-southeast1
+name: //servicedirectory.googleapis.com/projects/clever-span-387012/locations/asia-southeast1/namespaces/goog-psc-default/services/gk3-zotprime-k8s-dev-7cfbec75-b20aaa5f-pe/endpoints/default
+parentAssetType: servicedirectory.googleapis.com/Service
+parentFullResourceName: //servicedirectory.googleapis.com/projects/clever-span-387012/locations/asia-southeast1/namespaces/goog-psc-default/services/gk3-zotprime-k8s-dev-7cfbec75-b20aaa5f-pe
+project: projects/414581404162
+updateTime: '2023-05-23T03:16:25Z'
+---
+assetType: servicedirectory.googleapis.com/Namespace
+createTime: '2023-05-18T13:39:54Z'
+displayName: goog-psc-default
+location: asia-southeast1
+name: //servicedirectory.googleapis.com/projects/clever-span-387012/locations/asia-southeast1/namespaces/goog-psc-default
+parentAssetType: cloudresourcemanager.googleapis.com/Project
+parentFullResourceName: //cloudresourcemanager.googleapis.com/projects/clever-span-387012
+--
+displayName: goog-psc-default
+location: asia-southeast2
+name: //servicedirectory.googleapis.com/projects/clever-span-387012/locations/asia-southeast2/namespaces/goog-psc-default
+parentAssetType: cloudresourcemanager.googleapis.com/Project
+parentFullResourceName: //cloudresourcemanager.googleapis.com/projects/clever-span-387012
+project: projects/414581404162
+updateTime: '2023-05-18T11:46:58Z'
+---
+assetType: iam.googleapis.com/ServiceAccountKey
+createTime: '2023-05-18T00:17:24Z'
+displayName: projects/clever-span-387012/serviceAccounts/zotprimedev@clever-span-387012.iam.gserviceaccount.com/keys/c5f845ae9ab4d44411e076a2ed20bdc294dd654b
+name: //iam.googleapis.com/projects/clever-span-387012/serviceAccounts/100423131905129830241/keys/c5f845ae9ab4d44411e076a2ed20bdc294dd654b
+parentAssetType: iam.googleapis.com/ServiceAccount
+parentFullResourceName: //iam.googleapis.com/projects/clever-span-387012/serviceAccounts/zotprimedev@clever-span-387012.iam.gserviceaccount.com
+project: projects/414581404162
+---
+additionalAttributes:
+  email: zotprimedev@clever-span-387012.iam.gserviceaccount.com
+  uniqueId: '100423131905129830241'
+assetType: iam.googleapis.com/ServiceAccount
+name: //iam.googleapis.com/projects/clever-span-387012/serviceAccounts/zotprimedev@clever-span-387012.iam.gserviceaccount.com
+parentAssetType: cloudresourcemanager.googleapis.com/Project
+parentFullResourceName: //cloudresourcemanager.googleapis.com/projects/clever-span-387012
+project: projects/414581404162
+state: ENABLED
+---
+additionalAttributes:
+  email: zotprime-account@clever-span-387012.iam.gserviceaccount.com
+  uniqueId: '113908771108760021782'
+assetType: iam.googleapis.com/ServiceAccount
+description: zotprime
+displayName: zotprime-account
+name: //iam.googleapis.com/projects/clever-span-387012/serviceAccounts/zotprime-account@clever-span-387012.iam.gserviceaccount.com
+parentAssetType: cloudresourcemanager.googleapis.com/Project
+parentFullResourceName: //cloudresourcemanager.googleapis.com/projects/clever-span-387012
+project: projects/414581404162
+state: ENABLED
+---
+assetType: compute.googleapis.com/Route
+createTime: '2023-05-17T19:27:12Z'
+description: Default local route to the subnetwork 10.196.0.0/20.
+displayName: default-route-dba043b2dff2cf4d
+location: global
+--
+assetType: cloudresourcemanager.googleapis.com/Project
+createTime: '2023-05-17T12:11:58Z'
+displayName: My First Project
+location: global
+name: //cloudresourcemanager.googleapis.com/projects/clever-span-387012
+project: projects/414581404162
+state: ACTIVE
+---
+assetType: iam.googleapis.com/ServiceAccountKey
+createTime: '2023-05-17T11:43:25Z'
+displayName: projects/clever-span-387012/serviceAccounts/zotprimedev@clever-span-387012.iam.gserviceaccount.com/keys/4a9bd0e8e791c35a1d6e6338531e18138129c355
+name: //iam.googleapis.com/projects/clever-span-387012/serviceAccounts/100423131905129830241/keys/4a9bd0e8e791c35a1d6e6338531e18138129c355
+parentAssetType: iam.googleapis.com/ServiceAccount
+parentFullResourceName: //iam.googleapis.com/projects/clever-span-387012/serviceAccounts/zotprimedev@clever-span-387012.iam.gserviceaccount.com
+project: projects/414581404162
+---
+assetType: iam.googleapis.com/ServiceAccountKey
+createTime: '2023-05-17T00:00:27Z'
+displayName: projects/clever-span-387012/serviceAccounts/414581404162-compute@developer.gserviceaccount.com/keys/624df6fa5dcf48a290c86d7f976f367f7ec2d671
+name: //iam.googleapis.com/projects/clever-span-387012/serviceAccounts/115346479826571902556/keys/624df6fa5dcf48a290c86d7f976f367f7ec2d671
+parentAssetType: iam.googleapis.com/ServiceAccount
+parentFullResourceName: //iam.googleapis.com/projects/clever-span-387012/serviceAccounts/414581404162-compute@developer.gserviceaccount.com
+project: projects/414581404162
+---
+assetType: iam.googleapis.com/ServiceAccountKey
+createTime: '2023-05-17T00:00:16Z'
+displayName: projects/clever-span-387012/serviceAccounts/zotprime-account@clever-span-387012.iam.gserviceaccount.com/keys/5b4f1968d7aeaa9e87a278b7405261151d63e6f4
+name: //iam.googleapis.com/projects/clever-span-387012/serviceAccounts/113908771108760021782/keys/5b4f1968d7aeaa9e87a278b7405261151d63e6f4
+parentAssetType: iam.googleapis.com/ServiceAccount
+parentFullResourceName: //iam.googleapis.com/projects/clever-span-387012/serviceAccounts/zotprime-account@clever-span-387012.iam.gserviceaccount.com
+project: projects/414581404162
+---
+assetType: logging.googleapis.com/LogSink
+displayName: _Default
+location: global
+name: //logging.googleapis.com/projects/414581404162/sinks/_Default
+parentAssetType: cloudresourcemanager.googleapis.com/Project
+parentFullResourceName: //cloudresourcemanager.googleapis.com/projects/clever-span-387012
+project: projects/414581404162
+---
diff --git a/zotprime-k8s/GKE/terraform/output.tf b/zotprime-k8s/GKE/terraform/output.tf
new file mode 100644
index 00000000..5e8bac38
--- /dev/null
+++ b/zotprime-k8s/GKE/terraform/output.tf
@@ -0,0 +1,27 @@
+
+output "K8S_cluster_name" {
+  description = "GKE Cluster name"
+  value       = module.k8s.name
+}
+
+output "region" {
+  value       = var.region
+  description = "GCloud Region"
+}
+
+output "project_id" {
+  value       = var.project_id
+  description = "GCloud Project ID"
+}
+
+output "kubernetes_cluster_host" {
+  value       = module.k8s.endpoint
+  description = "GKE Cluster Host"
+  sensitive   = true
+}
+
+#output "kubernetes_cluster_labels" {
+#  value       = module.k8s.labels
+#  description = "GKE Cluster labels"
+#}
+
diff --git a/zotprime-k8s/GKE/terraform/terraform.tfvars_example b/zotprime-k8s/GKE/terraform/terraform.tfvars_example
new file mode 100644
index 00000000..7f561e07
--- /dev/null
+++ b/zotprime-k8s/GKE/terraform/terraform.tfvars_example
@@ -0,0 +1,15 @@
+project_id             = <addprojectname>
+region                 = "asia-southeast1"
+zones                  = ["asia-southeast1-b"]
+node-locations         = "asia-southeast1-b"
+minnode                = 1
+maxnode                = 2
+disksize               = 30
+machine                = "n1-standard-8"
+env_name               = "prod"
+cluster_name           = "zotprime-k8s"
+network                = "zotprime-network"
+subnetwork             = "zotprime-subnet"
+ip_range_pods_name     = "ip-range-pods"
+ip_range_services_name = "ip-range-services"
+service-account-id     = "serviceaccount-id"
diff --git a/zotprime-k8s/GKE/terraform/variables.tf b/zotprime-k8s/GKE/terraform/variables.tf
new file mode 100644
index 00000000..02a5eebd
--- /dev/null
+++ b/zotprime-k8s/GKE/terraform/variables.tf
@@ -0,0 +1,69 @@
+variable "project_id" {
+  description = "The project ID of your project"
+}
+variable "cluster_name" {
+  description = "The name for the GKE cluster"
+  default     = "gke-terraform"
+}
+variable "env_name" {
+  description = "The environment for the GKE cluster"
+  default     = "learn"
+}
+variable "region" {
+  description = "The region to host the cluster in"
+  default     = "asia-southeast1"
+}
+variable "zones" {
+  description = "Cluster zone"
+  default     = ["asia-southeast1-a", "asia-southeast1-b", "asia-southeast1-c"]
+}
+variable "network" {
+  description = "The VPC network created to host the cluster in"
+  default     = "gke-network"
+}
+variable "subnetwork" {
+  description = "The subnetwork created to host the cluster in"
+  default     = "gke-subnet"
+}
+variable "ip_range_pods_name" {
+  description = "The secondary ip range to use for pods"
+  default     = "ip-range-pods"
+}
+variable "ip_range_services_name" {
+  description = "The secondary ip range to use for services"
+  default     = "ip-range-services"
+}
+
+variable "service-account-id" {
+  description = "The ID of service account of GCP"
+  default     = "serviceaccount-id"
+}
+#variable "cpus" {
+#  description = "Number of cpus"
+#  default     = "2"
+#}
+
+variable "node-locations" {
+  description = "Node locations"
+  default     = "asia-southeast1-b"
+}
+
+variable "machine" {
+  description = "CGP Compute Engine"
+  default     = "n1-standard-1"
+}
+
+
+variable "minnode" {
+  description = "Minimum number of node pool"
+  default     = 1
+}
+variable "maxnode" {
+  description = "Maximum number of node pool"
+  default     = 2
+}
+
+variable "disksize" {
+  description = "Disk Size in GB"
+  default     = 10
+}
\ No newline at end of file
diff --git a/zotprime-k8s/GKE/zotprime-namespace.yaml b/zotprime-k8s/GKE/zotprime-namespace.yaml
new file mode 100644
index 00000000..764e2f42
--- /dev/null
+++ b/zotprime-k8s/GKE/zotprime-namespace.yaml
@@ -0,0 +1,7 @@
+# Deploys a new Namespace for the ZotPrime
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: zotprime
+  labels:
+    name: zotprime
\ No newline at end of file
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/Chart.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/Chart.yaml
new file mode 100644
index 00000000..05099510
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+description: A Helm Chart generated by Move2Kube for zotprime-k8s
+keywords:
+  - zotprime-k8s
+name: zotprime-k8s
+version: 0.1.0
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-tinymce-clean-server-deployment.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-tinymce-clean-server-deployment.yaml
new file mode 100644
index 00000000..ef3732bc
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-tinymce-clean-server-deployment.yaml
@@ -0,0 +1,34 @@
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+    creationTimestamp: null
+    labels:
+        move2kube.konveyor.io/service: app-tinymce-clean-server
+    name: app-tinymce-clean-server
+spec:
+    replicas: {{ index .Values "common" "replicas" }}
+    selector:
+        matchLabels:
+            move2kube.konveyor.io/service: app-tinymce-clean-server
+    strategy: {}
+    template:
+        metadata:
+            creationTimestamp: null
+            labels:
+                move2kube.konveyor.io/service: app-tinymce-clean-server
+            name: app-tinymce-clean-server
+        spec:
+            containers:
+                - image: localhost:32000/zotprime-k8s/app-tinymce-clean-server:latest
+                  imagePullPolicy: Always
+                  name: app-tinymce-clean-server
+                  ports:
+                    - containerPort: 16342
+                  resources: {}
+            restartPolicy: Always
+            securityContext: {}
+status: {}
+
+...
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-tinymce-clean-server-service.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-tinymce-clean-server-service.yaml
new file mode 100644
index 00000000..18d64509
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-tinymce-clean-server-service.yaml
@@ -0,0 +1,21 @@
+
+---
+apiVersion: v1
+kind: Service
+metadata:
+    creationTimestamp: null
+    labels:
+        move2kube.konveyor.io/service: app-tinymce-clean-server
+    name: app-tinymce-clean-server
+spec:
+    ports:
+        - name: port-16342
+          port: 16342
+          targetPort: 16342
+    selector:
+        move2kube.konveyor.io/service: app-tinymce-clean-server
+    type: ClusterIP
+status:
+    loadBalancer: {}
+
+...
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotero-stream-server-deployment.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotero-stream-server-deployment.yaml
new file mode 100644
index 00000000..c59670e7
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotero-stream-server-deployment.yaml
@@ -0,0 +1,34 @@
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+    creationTimestamp: null
+    labels:
+        move2kube.konveyor.io/service: app-zotero-stream-server
+    name: app-zotero-stream-server
+spec:
+    replicas: {{ index .Values "common" "replicas" }}
+    selector:
+        matchLabels:
+            move2kube.konveyor.io/service: app-zotero-stream-server
+    strategy: {}
+    template:
+        metadata:
+            creationTimestamp: null
+            labels:
+                move2kube.konveyor.io/service: app-zotero-stream-server
+            name: app-zotero-stream-server
+        spec:
+            containers:
+                - image: localhost:32000/zotprime-k8s/app-zotero-stream-server:latest
+                  imagePullPolicy: Always
+                  name: app-zotero-stream-server
+                  ports:
+                    - containerPort: 81
+                  resources: {}
+            restartPolicy: Always
+            securityContext: {}
+status: {}
+
+...
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotero-stream-server-service.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotero-stream-server-service.yaml
new file mode 100644
index 00000000..c09abcb1
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotero-stream-server-service.yaml
@@ -0,0 +1,21 @@
+
+---
+apiVersion: v1
+kind: Service
+metadata:
+    creationTimestamp: null
+    labels:
+        move2kube.konveyor.io/service: app-zotero-stream-server
+    name: app-zotero-stream-server
+spec:
+    ports:
+        - name: port-81
+          port: 81
+          targetPort: 81
+    selector:
+        move2kube.konveyor.io/service: app-zotero-stream-server
+    type: ClusterIP
+status:
+    loadBalancer: {}
+
+...
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotprime-deployment.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotprime-deployment.yaml
new file mode 100644
index 00000000..d174eaf8
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotprime-deployment.yaml
@@ -0,0 +1,34 @@
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+    creationTimestamp: null
+    labels:
+        move2kube.konveyor.io/service: zotprime-dataserver
+    name: zotprime-dataserver
+spec:
+    replicas: {{ index .Values "common" "replicas" }}
+    selector:
+        matchLabels:
+            move2kube.konveyor.io/service: zotprime-dataserver
+    strategy: {}
+    template:
+        metadata:
+            creationTimestamp: null
+            labels:
+                move2kube.konveyor.io/service: zotprime-dataserver
+            name: zotprime-dataserver
+        spec:
+            containers:
+                - image: localhost:32000/zotprime-k8s/zotprime-dataserver:latest
+                  imagePullPolicy: Always
+                  name: zotprime-dataserver
+                  ports:
+                    - containerPort: 8080
+                  resources: {}
+            restartPolicy: Always
+            securityContext: {}
+status: {}
+
+...
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotprime-service.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotprime-service.yaml
new file mode 100644
index 00000000..ff11081c
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/templates/app-zotprime-service.yaml
@@ -0,0 +1,21 @@
+
+---
+apiVersion: v1
+kind: Service
+metadata:
+    creationTimestamp: null
+    labels:
+        move2kube.konveyor.io/service: zotprime-dataserver
+    name: zotprime-dataserver
+spec:
+    ports:
+        - name: port-8080
+          port: 8080
+          targetPort: 8080
+    selector:
+        move2kube.konveyor.io/service: zotprime-dataserver
+    type: ClusterIP
+status:
+    loadBalancer: {}
+
+...
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/templates/db-zotprime-minio-deployment.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/templates/db-zotprime-minio-deployment.yaml
new file mode 100644
index 00000000..9cc9e1db
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/templates/db-zotprime-minio-deployment.yaml
@@ -0,0 +1,34 @@
+
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+    creationTimestamp: null
+    labels:
+        move2kube.konveyor.io/service: db-zotprime-minio
+    name: db-zotprime-minio
+spec:
+    replicas: {{ index .Values "common" "replicas" }}
+    selector:
+        matchLabels:
+            move2kube.konveyor.io/service: db-zotprime-minio
+    strategy: {}
+    template:
+        metadata:
+            creationTimestamp: null
+            labels:
+                move2kube.konveyor.io/service: db-zotprime-minio
+            name: db-zotprime-minio
+        spec:
+            containers:
+                - image: localhost:32000/zotprime-k8s/db-zotprime-minio:latest
+                  imagePullPolicy: Always
+                  name: db-zotprime-minio
+                  ports:
+                    - containerPort: 8080
+                  resources: {}
+            restartPolicy: Always
+            securityContext: {}
+status: {}
+
+...
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/templates/db-zotprime-minio-service.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/templates/db-zotprime-minio-service.yaml
new file mode 100644
index 00000000..c8ce035f
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/templates/db-zotprime-minio-service.yaml
@@ -0,0 +1,21 @@
+
+---
+apiVersion: v1
+kind: Service
+metadata:
+    creationTimestamp: null
+    labels:
+        move2kube.konveyor.io/service: db-zotprime-minio
+    name: db-zotprime-minio
+spec:
+    ports:
+        - name: port-8080
+          port: 8080
+          targetPort: 8080
+    selector:
+        move2kube.konveyor.io/service: db-zotprime-minio
+    type: ClusterIP
+status:
+    loadBalancer: {}
+
+...
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/templates/zotprime-k8s-ingress.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/templates/zotprime-k8s-ingress.yaml
new file mode 100644
index 00000000..e0a00024
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/templates/zotprime-k8s-ingress.yaml
@@ -0,0 +1,46 @@
+
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+    creationTimestamp: null
+    labels:
+        move2kube.konveyor.io/service: zotprime-k8s
+    name: zotprime-k8s
+spec:
+    rules:
+        - host: localhost
+          http:
+            paths:
+                - backend:
+                    service:
+                        name: zotprime-streamserver
+                        port:
+                            name: port-81
+                  path: /zotprime-streamserver
+                  pathType: Prefix
+                - backend:
+                    service:
+                        name: zotprime-tinymceclean
+                        port:
+                            name: port-16342
+                  path: /zotprime-tinymceclean
+                  pathType: Prefix
+                - backend:
+                    service:
+                        name: zotprime-minio
+                        port:
+                            name: port-8080
+                  path: /
+                  pathType: Prefix
+                - backend:
+                    service:
+                        name: zotprime-dataserver
+                        port:
+                            name: port-8080
+                  path: /
+                  pathType: Prefix
+status:
+    loadBalancer: {}
+
+...
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/values-dev.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/values-dev.yaml
new file mode 100644
index 00000000..0986b349
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/values-dev.yaml
@@ -0,0 +1,2 @@
+common:
+  replicas: 1
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/values-prod.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/values-prod.yaml
new file mode 100644
index 00000000..0986b349
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/values-prod.yaml
@@ -0,0 +1,2 @@
+common:
+  replicas: 1
diff --git a/zotprime-k8s/helm-chart/zotprime-k8s/values-staging.yaml b/zotprime-k8s/helm-chart/zotprime-k8s/values-staging.yaml
new file mode 100644
index 00000000..0986b349
--- /dev/null
+++ b/zotprime-k8s/helm-chart/zotprime-k8s/values-staging.yaml
@@ -0,0 +1,2 @@
+common:
+  replicas: 1
diff --git a/zotprime-k8s/microk8s/helm-chart/.helmignore b/zotprime-k8s/microk8s/helm-chart/.helmignore
new file mode 100644
index 00000000..0e8a0eb3
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/zotprime-k8s/microk8s/helm-chart/Chart.yaml b/zotprime-k8s/microk8s/helm-chart/Chart.yaml
new file mode 100644
index 00000000..8923f3f3
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/Chart.yaml
@@ -0,0 +1,21 @@
+apiVersion: v2
+name: zotprime-k8s
+description: A Helm chart for Kubernetes
+# A chart can be either an 'application' or a 'library' chart.
+#
+# Application charts are a collection of templates that can be packaged into versioned archives
+# to be deployed.
+#
+# Library charts provide useful utilities or functions for the chart developer. They're included as
+# a dependency of application charts to inject those utilities and functions into the rendering
+# pipeline. Library charts do not define any templates and therefore cannot be deployed.
+type: application
+# This is the chart version. This version number should be incremented each time you make changes
+# to the chart and its templates, including the app version.
+# Versions are expected to follow Semantic Versioning (https://semver.org/)
+version: 0.1.6
+# This is the version number of the application being deployed. This version number should be
+# incremented each time you make changes to the application. Versions are not expected to
+# follow Semantic Versioning. They should reflect the version the application is using.
+# It is recommended to use it with quotes.
+appVersion: "2.6.1-rc"
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/_helpers.tpl b/zotprime-k8s/microk8s/helm-chart/templates/_helpers.tpl
new file mode 100644
index 00000000..4d249735
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/_helpers.tpl
@@ -0,0 +1,62 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "zotprime-k8s.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
+If release name contains chart name it will be used as a full name.
+*/}}
+{{- define "zotprime-k8s.fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default .Chart.Name .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
+{{/*
+Create chart name and version as used by the chart label.
+*/}}
+{{- define "zotprime-k8s.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels
+*/}}
+{{- define "zotprime-k8s.labels" -}}
+helm.sh/chart: {{ include "zotprime-k8s.chart" . }}
+{{ include "zotprime-k8s.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Selector labels
+*/}}
+{{- define "zotprime-k8s.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "zotprime-k8s.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Create the name of the service account to use
+*/}}
+{{- define "zotprime-k8s.serviceAccountName" -}}
+{{- if .Values.serviceAccount.create }}
+{{- default (include "zotprime-k8s.fullname" .) .Values.serviceAccount.name }}
+{{- else }}
+{{- default "default" .Values.serviceAccount.name }}
+{{- end }}
+{{- end }}
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/dataminio.yaml b/zotprime-k8s/microk8s/helm-chart/templates/dataminio.yaml
new file mode 100644
index 00000000..5186c4ae
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/dataminio.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-dataminio
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  accessModes:
+  - ReadWriteOnce
+  resources:
+    requests:
+      storage: {{ .Values.pvc.dataminio.storageRequest | quote }}
+  storageClassName: {{ .Values.pvc.dataminio.storageClass | quote }}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/dataserver-config.yaml b/zotprime-k8s/microk8s/helm-chart/templates/dataserver-config.yaml
new file mode 100644
index 00000000..9913508d
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/dataserver-config.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-dataserver-config
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  init.sh: {{ .Values.dataserverConfig.initSh | toYaml | indent 1 }}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/dataserver.yaml b/zotprime-k8s/microk8s/helm-chart/templates/dataserver.yaml
new file mode 100644
index 00000000..964a0b10
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/dataserver.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: dataserver
+  labels:
+    apps: dataserver
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.dataserver.type }}
+  selector:
+    apps: zotprime-dataserver
+  ports:
+	{{- .Values.dataserver.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/db-config.yaml b/zotprime-k8s/microk8s/helm-chart/templates/db-config.yaml
new file mode 100644
index 00000000..3307794f
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/db-config.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-db-config
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  mariadb-databasename: {{ .Values.dbConfig.mariadbDatabasename | quote }}
+  mariadb-user: {{ .Values.dbConfig.mariadbUser | quote }}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/db-secret.yaml b/zotprime-k8s/microk8s/helm-chart/templates/db-secret.yaml
new file mode 100644
index 00000000..91e8e829
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/db-secret.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-db-secret
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  secret-db.txt: {{ required "dbSecret.secretTxt is required" .Values.dbSecret.secretTxt }}
+type: Opaque
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/deployment.yaml b/zotprime-k8s/microk8s/helm-chart/templates/deployment.yaml
new file mode 100644
index 00000000..46f113c2
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/deployment.yaml
@@ -0,0 +1,532 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-dataserver
+  labels:
+    apps: zotprime-dataserver
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeDataserver.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-dataserver
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-dataserver
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - env:
+        - name: DSURI
+          value: {{ quote .Values.zotprimeDataserver.zotprimeDataserver.env.dsuri }}
+        - name: S3POINTURI
+          value: {{ quote .Values.zotprimeDataserver.zotprimeDataserver.env.s3Pointuri
+            }}
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeDataserver.zotprimeDataserver.image.repository }}:{{
+          .Values.zotprimeDataserver.zotprimeDataserver.image.tag | default .Chart.AppVersion
+          }}
+        imagePullPolicy: {{ .Values.zotprimeDataserver.zotprimeDataserver.imagePullPolicy
+          }}
+        lifecycle:
+          postStart:
+            exec:
+              command:
+              - /bin/sh
+              - -xc
+              - sleep 180; bash /tmp/_conf/init.sh ; exit 0
+        livenessProbe:
+          httpGet:
+            path: /
+            port: 80
+          initialDelaySeconds: 195
+          periodSeconds: 30
+        name: zotprime-dataserver
+        ports:
+        - containerPort: 80
+        readinessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /
+            port: 80
+          initialDelaySeconds: 190
+          periodSeconds: 30
+          successThreshold: 1
+          timeoutSeconds: 1
+        resources: {{- toYaml .Values.zotprimeDataserver.zotprimeDataserver.resources |
+          nindent 10 }}
+        volumeMounts:
+        - mountPath: /tmp/_conf
+          name: dataserver-config
+          readOnly: true
+      hostAliases:
+      - hostnames:
+        - s3min.projectdev.net
+        ip: 10.30.11.11
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-low-priority
+      restartPolicy: Always
+      securityContext: {}
+      volumes:
+      - configMap:
+          name: {{ include "zotprime-k8s.fullname" . }}-dataserver-config
+        name: dataserver-config
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-localstack
+  labels:
+    apps: zotprime-localstack
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeLocalstack.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-localstack
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-localstack
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - env:
+        - name: SERVICES
+          value: {{ quote .Values.zotprimeLocalstack.localstack.env.services }}
+        - name: DEBUG
+          value: {{ quote .Values.zotprimeLocalstack.localstack.env.debug }}
+        - name: LOCALSTACK_K8S_SERVICE_NAME
+          value: {{ quote .Values.zotprimeLocalstack.localstack.env.localstackK8SServiceName
+            }}
+        - name: LOCALSTACK_K8S_NAMESPACE
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.namespace
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeLocalstack.localstack.image.repository }}:{{ .Values.zotprimeLocalstack.localstack.image.tag
+          | default .Chart.AppVersion }}
+        imagePullPolicy: {{ .Values.zotprimeLocalstack.localstack.imagePullPolicy }}
+        name: localstack
+        ports:
+        - containerPort: 4566
+          name: edge
+          protocol: TCP
+        - containerPort: 4575
+          name: sns-4575
+          protocol: TCP
+        - containerPort: 4576
+          name: sqs-4576
+          protocol: TCP
+        - containerPort: 4567
+          name: apigateway-4567
+          protocol: TCP
+        - containerPort: 4510
+          name: ext-svc-4510
+          protocol: TCP
+        - containerPort: 4511
+          name: ext-svc-4511
+          protocol: TCP
+        - containerPort: 4512
+          name: ext-svc-4512
+          protocol: TCP
+        - containerPort: 4513
+          name: ext-svc-4513
+          protocol: TCP
+        - containerPort: 4514
+          name: ext-svc-4514
+          protocol: TCP
+        - containerPort: 4515
+          name: ext-svc-4515
+          protocol: TCP
+        - containerPort: 4516
+          name: ext-svc-4516
+          protocol: TCP
+        - containerPort: 4517
+          name: ext-svc-4517
+          protocol: TCP
+        - containerPort: 4518
+          name: ext-svc-4518
+          protocol: TCP
+        - containerPort: 4519
+          name: ext-svc-4519
+          protocol: TCP
+        - containerPort: 4520
+          name: ext-svc-4520
+          protocol: TCP
+        - containerPort: 4521
+          name: ext-svc-4521
+          protocol: TCP
+        - containerPort: 4522
+          name: ext-svc-4522
+          protocol: TCP
+        - containerPort: 4523
+          name: ext-svc-4523
+          protocol: TCP
+        - containerPort: 4524
+          name: ext-svc-4524
+          protocol: TCP
+        - containerPort: 4525
+          name: ext-svc-4525
+          protocol: TCP
+        - containerPort: 4526
+          name: ext-svc-4526
+          protocol: TCP
+        - containerPort: 4527
+          name: ext-svc-4527
+          protocol: TCP
+        - containerPort: 4528
+          name: ext-svc-4528
+          protocol: TCP
+        - containerPort: 4529
+          name: ext-svc-4529
+          protocol: TCP
+        - containerPort: 4530
+          name: ext-svc-4530
+          protocol: TCP
+        - containerPort: 4531
+          name: ext-svc-4531
+          protocol: TCP
+        - containerPort: 4532
+          name: ext-svc-4532
+          protocol: TCP
+        - containerPort: 4533
+          name: ext-svc-4533
+          protocol: TCP
+        - containerPort: 4534
+          name: ext-svc-4534
+          protocol: TCP
+        - containerPort: 4535
+          name: ext-svc-4535
+          protocol: TCP
+        - containerPort: 4536
+          name: ext-svc-4536
+          protocol: TCP
+        - containerPort: 4537
+          name: ext-svc-4537
+          protocol: TCP
+        - containerPort: 4538
+          name: ext-svc-4538
+          protocol: TCP
+        - containerPort: 4539
+          name: ext-svc-4539
+          protocol: TCP
+        - containerPort: 4540
+          name: ext-svc-4540
+          protocol: TCP
+        - containerPort: 4541
+          name: ext-svc-4541
+          protocol: TCP
+        - containerPort: 4542
+          name: ext-svc-4542
+          protocol: TCP
+        - containerPort: 4543
+          name: ext-svc-4543
+          protocol: TCP
+        - containerPort: 4544
+          name: ext-svc-4544
+          protocol: TCP
+        - containerPort: 4545
+          name: ext-svc-4545
+          protocol: TCP
+        - containerPort: 4546
+          name: ext-svc-4546
+          protocol: TCP
+        - containerPort: 4547
+          name: ext-svc-4547
+          protocol: TCP
+        - containerPort: 4548
+          name: ext-svc-4548
+          protocol: TCP
+        - containerPort: 4549
+          name: ext-svc-4549
+          protocol: TCP
+        - containerPort: 4550
+          name: ext-svc-4550
+          protocol: TCP
+        - containerPort: 4551
+          name: ext-svc-4551
+          protocol: TCP
+        - containerPort: 4552
+          name: ext-svc-4552
+          protocol: TCP
+        - containerPort: 4553
+          name: ext-svc-4553
+          protocol: TCP
+        - containerPort: 4554
+          name: ext-svc-4554
+          protocol: TCP
+        - containerPort: 4555
+          name: ext-svc-4555
+          protocol: TCP
+        - containerPort: 4556
+          name: ext-svc-4556
+          protocol: TCP
+        - containerPort: 4557
+          name: ext-svc-4557
+          protocol: TCP
+        - containerPort: 4558
+          name: ext-svc-4558
+          protocol: TCP
+        - containerPort: 4559
+          name: ext-svc-4559
+          protocol: TCP
+        resources: {{- toYaml .Values.zotprimeLocalstack.localstack.resources | nindent
+          10 }}
+        securityContext: {{- toYaml .Values.zotprimeLocalstack.localstack.containerSecurityContext
+          | nindent 10 }}
+      securityContext: {}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-memcached
+  labels:
+    apps: zotprime-memcached
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeMemcached.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-memcached
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-memcached
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - args: {{- toYaml .Values.zotprimeMemcached.memcached.args | nindent 8 }}
+        env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeMemcached.memcached.image.repository }}:{{ .Values.zotprimeMemcached.memcached.image.tag
+          | default .Chart.AppVersion }}
+        name: memcached
+        ports:
+        - containerPort: 11211
+          protocol: TCP
+        resources: {{- toYaml .Values.zotprimeMemcached.memcached.resources | nindent 10
+          }}
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-high-priority
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-minio
+  labels:
+    apps: zotprime-minio
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeMinio.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-minio
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-minio
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - args: {{- toYaml .Values.zotprimeMinio.zotprimeMinio.args | nindent 8 }}
+        command:
+        - /bin/bash
+        - -c
+        env:
+        - name: MINIO_ROOT_USER
+          valueFrom:
+            configMapKeyRef:
+              key: minio-user
+              name: {{ include "zotprime-k8s.fullname" . }}-minio-config
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeMinio.zotprimeMinio.image.repository }}:{{ .Values.zotprimeMinio.zotprimeMinio.image.tag
+          | default .Chart.AppVersion }}
+        imagePullPolicy: {{ .Values.zotprimeMinio.zotprimeMinio.imagePullPolicy }}
+        livenessProbe:
+          httpGet:
+            path: /minio/health/live
+            port: 9000
+          initialDelaySeconds: 20
+          periodSeconds: 30
+          timeoutSeconds: 20
+        name: zotprime-minio
+        ports:
+        - containerPort: 9000
+          name: data
+        - containerPort: 9001
+          name: ui
+        readinessProbe:
+          failureThreshold: 5
+          httpGet:
+            path: /minio/health/live
+            port: 9000
+          initialDelaySeconds: 10
+          periodSeconds: 25
+          successThreshold: 1
+          timeoutSeconds: 1
+        resources: {{- toYaml .Values.zotprimeMinio.zotprimeMinio.resources | nindent 10
+          }}
+        volumeMounts:
+        - mountPath: /data
+          name: s3data
+        - mountPath: /tmp/_key
+          name: {{ include "zotprime-k8s.fullname" . }}-minio-secret
+          readOnly: true
+      - env:
+        - name: MINIO_ROOT_USER
+          valueFrom:
+            configMapKeyRef:
+              key: minio-user
+              name: {{ include "zotprime-k8s.fullname" . }}-minio-config
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeMinio.zotprimeMiniomc.image.repository }}:{{ .Values.zotprimeMinio.zotprimeMiniomc.image.tag
+          | default .Chart.AppVersion }}
+        name: zotprime-miniomc
+        resources: {{- toYaml .Values.zotprimeMinio.zotprimeMiniomc.resources | nindent
+          10 }}
+        volumeMounts:
+        - mountPath: /data
+          name: s3data
+        - mountPath: /tmp/_key
+          name: {{ include "zotprime-k8s.fullname" . }}-minio-secret
+          readOnly: true
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-high-priority
+      restartPolicy: Always
+      securityContext: {}
+      volumes:
+      - name: s3data
+        persistentVolumeClaim:
+          claimName: {{ include "zotprime-k8s.fullname" . }}-dataminio
+      - name: {{ include "zotprime-k8s.fullname" . }}-minio-secret
+        secret:
+          secretName: {{ include "zotprime-k8s.fullname" . }}-minio-secret
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-phpmyadmin
+  labels:
+    apps: zotprime-phpmyadmin
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  selector:
+    matchLabels:
+      apps: zotprime-phpmyadmin
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-phpmyadmin
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - env:
+        - name: PMA_HOST
+          value: {{ quote .Values.zotprimePhpmyadmin.phpmyadmin.env.pmaHost }}
+        - name: PMA_PORT
+          value: {{ quote .Values.zotprimePhpmyadmin.phpmyadmin.env.pmaPort }}
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimePhpmyadmin.phpmyadmin.image.repository }}:{{ .Values.zotprimePhpmyadmin.phpmyadmin.image.tag
+          | default .Chart.AppVersion }}
+        name: phpmyadmin
+        ports:
+        - containerPort: 80
+        resources: {{- toYaml .Values.zotprimePhpmyadmin.phpmyadmin.resources | nindent
+          10 }}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-streamserver
+  labels:
+    apps: zotprime-streamserver
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeStreamserver.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-streamserver
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-streamserver
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeStreamserver.zotprimeStreamserver.image.repository }}:{{
+          .Values.zotprimeStreamserver.zotprimeStreamserver.image.tag | default .Chart.AppVersion
+          }}
+        livenessProbe:
+          httpGet:
+            path: /health
+            port: 81
+          initialDelaySeconds: 10
+          periodSeconds: 30
+          timeoutSeconds: 20
+        name: zotprime-streamserver
+        ports:
+        - containerPort: 81
+        readinessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /health
+            port: 81
+          periodSeconds: 20
+          successThreshold: 1
+          timeoutSeconds: 1
+        resources: {{- toYaml .Values.zotprimeStreamserver.zotprimeStreamserver.resources
+          | nindent 10 }}
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-medium-priority
+      restartPolicy: Always
+      securityContext: {}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-tinymceclean
+  labels:
+    apps: zotprime-tinymceclean
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeTinymceclean.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-tinymceclean
+    {{- include "zotprime-k8s.selectorLabels" . | nindent 6 }}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-tinymceclean
+      {{- include "zotprime-k8s.selectorLabels" . | nindent 8 }}
+    spec:
+      containers:
+      - env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeTinymceclean.zotprimeTinymceclean.image.repository }}:{{
+          .Values.zotprimeTinymceclean.zotprimeTinymceclean.image.tag | default .Chart.AppVersion
+          }}
+        imagePullPolicy: {{ .Values.zotprimeTinymceclean.zotprimeTinymceclean.imagePullPolicy
+          }}
+        name: zotprime-tinymceclean
+        ports:
+        - containerPort: 16342
+        resources: {{- toYaml .Values.zotprimeTinymceclean.zotprimeTinymceclean.resources
+          | nindent 10 }}
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-medium-priority
+      restartPolicy: Always
+      securityContext: {}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/elasticsearch.yaml b/zotprime-k8s/microk8s/helm-chart/templates/elasticsearch.yaml
new file mode 100644
index 00000000..51f333cf
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/elasticsearch.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: elasticsearch
+  labels:
+    apps: elasticsearch
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.elasticsearch.type }}
+  selector:
+    apps: zotprime-elasticsearch
+  ports:
+	{{- .Values.elasticsearch.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/high-priority.yaml b/zotprime-k8s/microk8s/helm-chart/templates/high-priority.yaml
new file mode 100644
index 00000000..13f2ab36
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/high-priority.yaml
@@ -0,0 +1,9 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-high-priority
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
+value: 1000000
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/localstack.yaml b/zotprime-k8s/microk8s/helm-chart/templates/localstack.yaml
new file mode 100644
index 00000000..03146da4
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/localstack.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: localstack
+  labels:
+    apps: localstack
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.localstack.type }}
+  selector:
+    apps: zotprime-localstack
+  ports:
+	{{- .Values.localstack.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/low-priority.yaml b/zotprime-k8s/microk8s/helm-chart/templates/low-priority.yaml
new file mode 100644
index 00000000..99fb1f52
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/low-priority.yaml
@@ -0,0 +1,9 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-low-priority
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
+value: 800000
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/medium-priority.yaml b/zotprime-k8s/microk8s/helm-chart/templates/medium-priority.yaml
new file mode 100644
index 00000000..07a6de1d
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/medium-priority.yaml
@@ -0,0 +1,9 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-medium-priority
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
+value: 900000
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/memcached.yaml b/zotprime-k8s/microk8s/helm-chart/templates/memcached.yaml
new file mode 100644
index 00000000..c4f5c634
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/memcached.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: memcached
+  labels:
+    apps: memcached
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.memcached.type }}
+  selector:
+    apps: zotprime-memcached
+  ports:
+	{{- .Values.memcached.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/minio-config.yaml b/zotprime-k8s/microk8s/helm-chart/templates/minio-config.yaml
new file mode 100644
index 00000000..1832f76f
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/minio-config.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-minio-config
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  minio-user: {{ .Values.minioConfig.minioUser | quote }}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/minio-secret.yaml b/zotprime-k8s/microk8s/helm-chart/templates/minio-secret.yaml
new file mode 100644
index 00000000..18251c1d
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/minio-secret.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-minio-secret
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  secret-minio.txt: {{ required "minioSecret.secretTxt is required" .Values.minioSecret.secretTxt }}
+type: Opaque
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/minio.yaml b/zotprime-k8s/microk8s/helm-chart/templates/minio.yaml
new file mode 100644
index 00000000..96bbcceb
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/minio.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: minio
+  labels:
+    apps: minio
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.minio.type }}
+  clusterIP: 10.152.183.11
+  selector:
+    apps: zotprime-minio
+  ports:
+	{{- .Values.minio.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/mysql.yaml b/zotprime-k8s/microk8s/helm-chart/templates/mysql.yaml
new file mode 100644
index 00000000..4a5ec55c
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/mysql.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: mysql
+  labels:
+    apps: mysql
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+  annotations:
+    service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
+spec:
+  type: {{ .Values.mysql.type }}
+  selector:
+    apps: zotprime-db
+  ports:
+	{{- .Values.mysql.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/phpmyadmin.yaml b/zotprime-k8s/microk8s/helm-chart/templates/phpmyadmin.yaml
new file mode 100644
index 00000000..78ada058
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/phpmyadmin.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: phpmyadmin
+  labels:
+    apps: phpmyadmin
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.phpmyadmin.type }}
+  selector:
+    apps: zotprime-phpmyadmin
+  ports:
+	{{- .Values.phpmyadmin.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/redis-config.yaml b/zotprime-k8s/microk8s/helm-chart/templates/redis-config.yaml
new file mode 100644
index 00000000..68c51a07
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/redis-config.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-redis-config
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  redis.conf: {{ .Values.redisConfig.redisConf | toYaml | indent 1 }}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/redis-probes.yaml b/zotprime-k8s/microk8s/helm-chart/templates/redis-probes.yaml
new file mode 100644
index 00000000..c5adb693
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/redis-probes.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-redis-probes
+  labels:
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+data:
+  liveness.sh: {{ .Values.redisProbes.livenessSh | toYaml | indent 1 }}
+  readiness.sh: {{ .Values.redisProbes.readinessSh | toYaml | indent 1 }}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/redis.yaml b/zotprime-k8s/microk8s/helm-chart/templates/redis.yaml
new file mode 100644
index 00000000..61753cb3
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/redis.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: redis
+  labels:
+    apps: redis
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.redis.type }}
+  selector:
+    apps: zotprime-redis
+  ports:
+	{{- .Values.redis.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/statefulset.yaml b/zotprime-k8s/microk8s/helm-chart/templates/statefulset.yaml
new file mode 100644
index 00000000..589b068e
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/statefulset.yaml
@@ -0,0 +1,266 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-db
+  labels:
+    apps: zotprime-db
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeDb.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-db
+  serviceName: db-service
+  template:
+    metadata:
+      labels:
+        apps: zotprime-db
+    spec:
+      containers:
+      - args: {{- toYaml .Values.zotprimeDb.mariadb.args | nindent 8 }}
+        command:
+        - /bin/bash
+        - -c
+        env:
+        - name: MARIADB_USER
+          valueFrom:
+            configMapKeyRef:
+              key: mariadb-user
+              name: {{ include "zotprime-k8s.fullname" . }}-db-config
+        - name: MARIADB_DATABASE
+          valueFrom:
+            configMapKeyRef:
+              key: mariadb-databasename
+              name: {{ include "zotprime-k8s.fullname" . }}-db-config
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeDb.mariadb.image.repository }}:{{ .Values.zotprimeDb.mariadb.image.tag
+          | default .Chart.AppVersion }}
+        name: mariadb
+        ports:
+        - containerPort: 3306
+          name: mariadb-port
+        resources: {{- toYaml .Values.zotprimeDb.mariadb.resources | nindent 10 }}
+        securityContext: {{- toYaml .Values.zotprimeDb.mariadb.containerSecurityContext
+          | nindent 10 }}
+        volumeMounts:
+        - mountPath: /var/lib/mysql/
+          name: datadb
+        - mountPath: tmp/_key/
+          name: {{ include "zotprime-k8s.fullname" . }}-db-secret
+      initContainers:
+      - command:
+        - chown
+        - -R
+        - 999:999
+        - /var/lib/mysql
+        env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeDb.takeDataDirOwnership.image.repository }}:{{ .Values.zotprimeDb.takeDataDirOwnership.image.tag
+          | default .Chart.AppVersion }}
+        name: take-data-dir-ownership
+        resources: {}
+        volumeMounts:
+        - mountPath: /var/lib/mysql
+          name: datadb
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-high-priority
+      volumes:
+      - name: {{ include "zotprime-k8s.fullname" . }}-db-secret
+        secret:
+          secretName: {{ include "zotprime-k8s.fullname" . }}-db-secret
+  updateStrategy: {}
+  volumeClaimTemplates:
+  - metadata:
+      creationTimestamp: null
+      name: datadb
+    spec:
+      accessModes:
+      - ReadWriteOnce
+      resources: {{ .Values.zotprimeDb.volumeClaims.datadb | toYaml | nindent 8 }}
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-elasticsearch
+  labels:
+    apps: zotprime-elasticsearch
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeElasticsearch.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-elasticsearch
+  serviceName: elasticsearch-masterservice
+  template:
+    metadata:
+      labels:
+        apps: zotprime-elasticsearch
+    spec:
+      containers:
+      - env:
+        - name: CLUSTER_NAME
+          value: {{ quote .Values.zotprimeElasticsearch.elasticsearch.env.clusterName
+            }}
+        - name: xpack.security.enabled
+          value: {{ quote .Values.zotprimeElasticsearch.elasticsearch.env.xpackSecurityEnabled
+            }}
+        - name: cluster.routing.allocation.disk.threshold_enabled
+          value: {{ quote .Values.zotprimeElasticsearch.elasticsearch.env.clusterRoutingAllocationDiskThresholdEnabled
+            }}
+        - name: discovery.type
+          value: {{ quote .Values.zotprimeElasticsearch.elasticsearch.env.discoveryType
+            }}
+        - name: NAMESPACE
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.namespace
+        - name: PROCESSORS
+          valueFrom:
+            resourceFieldRef:
+              divisor: "0"
+              resource: limits.cpu
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeElasticsearch.elasticsearch.image.repository }}:{{
+          .Values.zotprimeElasticsearch.elasticsearch.image.tag | default .Chart.AppVersion
+          }}
+        name: elasticsearch
+        ports:
+        - containerPort: 9300
+          name: transport
+        resources: {{- toYaml .Values.zotprimeElasticsearch.elasticsearch.resources
+          | nindent 10 }}
+        volumeMounts:
+        - mountPath: /data
+          name: dataes
+      initContainers:
+      - command:
+        - sysctl
+        - -w
+        - vm.max_map_count=262144
+        env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeElasticsearch.initSysctl.image.repository }}:{{ .Values.zotprimeElasticsearch.initSysctl.image.tag
+          | default .Chart.AppVersion }}
+        name: init-sysctl
+        resources: {}
+        securityContext: {{- toYaml .Values.zotprimeElasticsearch.initSysctl.containerSecurityContext
+          | nindent 10 }}
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-high-priority
+  updateStrategy: {}
+  volumeClaimTemplates:
+  - metadata:
+      creationTimestamp: null
+      name: dataes
+    spec:
+      accessModes:
+      - ReadWriteOnce
+      resources: {{ .Values.zotprimeElasticsearch.volumeClaims.dataes | toYaml | nindent
+        8 }}
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-redis
+  labels:
+    apps: zotprime-redis
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  replicas: {{ .Values.zotprimeRedis.replicas }}
+  selector:
+    matchLabels:
+      apps: zotprime-redis
+  serviceName: redis-service
+  template:
+    metadata:
+      labels:
+        apps: zotprime-redis
+    spec:
+      affinity:
+        podAntiAffinity:
+          preferredDuringSchedulingIgnoredDuringExecution:
+          - podAffinityTerm:
+              labelSelector:
+                matchExpressions:
+                - key: app
+                  operator: In
+                  values:
+                  - redis
+              topologyKey: kubernetes.io/hostname
+            weight: 100
+      containers:
+      - args: {{- toYaml .Values.zotprimeRedis.redis.args | nindent 8 }}
+        command:
+        - redis-server
+        env:
+        - name: KUBERNETES_CLUSTER_DOMAIN
+          value: {{ quote .Values.kubernetesClusterDomain }}
+        image: {{ .Values.zotprimeRedis.redis.image.repository }}:{{ .Values.zotprimeRedis.redis.image.tag
+          | default .Chart.AppVersion }}
+        livenessProbe:
+          exec:
+            command:
+            - sh
+            - -c
+            - /probes/liveness.sh
+          failureThreshold: 5
+          periodSeconds: 5
+          successThreshold: 1
+          timeoutSeconds: 5
+        name: redis
+        ports:
+        - containerPort: 6379
+          name: redis
+          protocol: TCP
+        - containerPort: 16379
+          name: cluster
+          protocol: TCP
+        readinessProbe:
+          exec:
+            command:
+            - sh
+            - -c
+            - /probes/readiness.sh
+          failureThreshold: 5
+          periodSeconds: 5
+          successThreshold: 1
+          timeoutSeconds: 1
+        resources: {{- toYaml .Values.zotprimeRedis.redis.resources | nindent 10 }}
+        startupProbe:
+          failureThreshold: 20
+          periodSeconds: 5
+          successThreshold: 1
+          tcpSocket:
+            port: redis
+          timeoutSeconds: 5
+        volumeMounts:
+        - mountPath: /conf
+          name: conf
+        - mountPath: /data
+          name: dataredis
+        - mountPath: /probes
+          name: probes
+          readOnly: true
+      priorityClassName: {{ include "zotprime-k8s.fullname" . }}-high-priority
+      terminationGracePeriodSeconds: 20
+      volumes:
+      - configMap:
+          defaultMode: 493
+          name: {{ include "zotprime-k8s.fullname" . }}-redis-config
+        name: conf
+      - configMap:
+          defaultMode: 365
+          name: {{ include "zotprime-k8s.fullname" . }}-redis-probes
+        name: probes
+  updateStrategy: {}
+  volumeClaimTemplates:
+  - metadata:
+      creationTimestamp: null
+      name: dataredis
+    spec:
+      accessModes:
+      - ReadWriteOnce
+      resources: {{ .Values.zotprimeRedis.volumeClaims.dataredis | toYaml | nindent
+        8 }}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/streamserver.yaml b/zotprime-k8s/microk8s/helm-chart/templates/streamserver.yaml
new file mode 100644
index 00000000..1f95348f
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/streamserver.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: streamserver
+  labels:
+    apps: streamserver
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.streamserver.type }}
+  selector:
+    apps: zotprime-streamserver
+  ports:
+	{{- .Values.streamserver.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/tinymceclean.yaml b/zotprime-k8s/microk8s/helm-chart/templates/tinymceclean.yaml
new file mode 100644
index 00000000..0bb576a6
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/tinymceclean.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: tinymceclean
+  labels:
+    apps: tinymceclean
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+spec:
+  type: {{ .Values.tinymceclean.type }}
+  selector:
+    apps: zotprime-tinymceclean
+  ports:
+	{{- .Values.tinymceclean.ports | toYaml | nindent 2 -}}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/zotprime-ingress-http.yaml b/zotprime-k8s/microk8s/helm-chart/templates/zotprime-ingress-http.yaml
new file mode 100644
index 00000000..e3d2f729
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/zotprime-ingress-http.yaml
@@ -0,0 +1,59 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-ingress-http
+  labels:
+    apps: zotprime-ingress-http
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+  annotations:
+#    cert-manager.io/issuer: letsencrypt-staging
+    nginx.ingress.kubernetes.io/rewrite-target: /
+spec:
+#  tls:
+#   - secretName: web-ssl
+#     hosts:
+#      - {{ .Values.ingressHostnames.minios3Web }}
+#      - {{ .Values.ingressHostnames.minios3Data }}
+#      - {{ .Values.ingressHostnames.phpmyadmin }}
+#      - {{ .Values.ingressHostnames.api }}
+  rules:
+  - host: {{ .Values.ingressHostnames.minios3Web }}
+    http:
+      paths:
+      - backend:
+          service:
+            name: minio
+            port:
+              name: minio-ui
+        path: /
+        pathType: Prefix
+  - host: {{ .Values.ingressHostnames.minios3Data }}
+    http:
+      paths:
+      - backend:
+          service:
+            name: minio
+            port:
+              name: minio-data
+        path: /
+        pathType: Prefix
+  - host: {{ .Values.ingressHostnames.phpmyadmin }}
+    http:
+      paths:
+      - backend:
+          service:
+            name: phpmyadmin
+            port:
+              number: 80
+        path: /
+        pathType: Prefix
+  - host: {{ .Values.ingressHostnames.api }}
+    http:
+      paths:
+      - backend:
+          service:
+            name: dataserver
+            port:
+              name: dataserver-api
+        path: /
+        pathType: Prefix
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/templates/zotprime-ingress-websocket.yaml b/zotprime-k8s/microk8s/helm-chart/templates/zotprime-ingress-websocket.yaml
new file mode 100644
index 00000000..e997f192
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/templates/zotprime-ingress-websocket.yaml
@@ -0,0 +1,39 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  name: {{ include "zotprime-k8s.fullname" . }}-zotprime-ingress-websocket
+  labels:
+    apps: zotprime-ingress-websocket
+  {{- include "zotprime-k8s.labels" . | nindent 4 }}
+  annotations:
+#    cert-manager.io/issuer: letsencrypt-staging
+    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
+    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
+    nginx.ingress.kubernetes.io/rewrite-target: /
+    nginx.ingress.kubernetes.io/server-snippets: |
+      location / {
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_http_version 1.1;
+        proxy_set_header X-Forwarded-Host $http_host;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Forwarded-For $remote_addr;
+        proxy_set_header Host $host;
+        proxy_set_header Connection "upgrade";
+        proxy_cache_bypass $http_upgrade;
+      }
+spec:
+#  tls:
+#   - secretName: web-ssl
+#     hosts:
+#      - {{ .Values.ingressHostnames.streamserver }}
+  rules:
+  - host: {{ .Values.ingressHostnames.streamserver }}
+    http:
+      paths:
+      - backend:
+          service:
+            name: streamserver
+            port:
+              name: streamserver
+        path: /
+        pathType: Prefix
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/helm-chart/values.yaml b/zotprime-k8s/microk8s/helm-chart/values.yaml
new file mode 100644
index 00000000..3f48b7a4
--- /dev/null
+++ b/zotprime-k8s/microk8s/helm-chart/values.yaml
@@ -0,0 +1,318 @@
+ingressHostnames:
+  api: api.zotprime
+  streamserver: stream.zotprime
+  minios3Data: s3min.zotprime
+  phpmyadmin: pm.zotprime
+  minios3Web: min.zotprime
+zotprimeDataserver:
+  replicas: 1
+  zotprimeDataserver:
+    env:
+      dsuri: http://api.zotprime/
+      s3Pointuri: s3min.zotprime
+    image:
+      repository: localhost:32000/zotprime-k8s/zotprime-dataserver
+      tag: latest
+    imagePullPolicy: Always
+    resources:
+      limits:
+        memory: 512Mi
+      requests:
+        cpu: "1"
+        memory: 512Mi
+minioSecret:
+  secretTxt: "TUlOSU9fUk9PVF9QQVNTV09SRD16b3Rlcm9kb2NrZXIK"
+dbSecret:
+  secretTxt: "TUFSSUFEQl9ST09UX1BBU1NXT1JEPXpvdGVybwpNQVJJQURCX1BBU1NXT1JEPXpvdGVyb3Rlc3QK"
+dataserver:
+  ports:
+  - name: dataserver-api
+    port: 80
+    targetPort: 80
+  type: ClusterIP
+dataserverConfig:
+  initSh: |-
+    #!/bin/sh
+    set -eux
+    cd /var/www/zotero/misc && ./init-mysql.sh
+    cd /var/www/zotero/misc && ./db_update.sh
+    aws --endpoint-url "http://minio:9000" s3 mb s3://zotero
+    aws --endpoint-url "http://minio:9000" s3 mb s3://zotero-fulltext
+    aws --endpoint-url "http://localstack:4575" sns create-topic --name zotero
+dbConfig:
+  mariadbDatabasename: zoterotest
+  mariadbUser: zotero
+elasticsearch:
+  ports:
+  - name: elasticsearch
+    port: 9300
+    targetPort: 0
+  type: ClusterIP
+kubernetesClusterDomain: cluster.local
+localstack:
+  ports:
+  - name: localstack-sns
+    port: 4575
+    targetPort: sns-4575
+  - name: localstack-sqs
+    port: 4576
+    targetPort: sqs-4576
+  - name: localstack-apigateway
+    port: 4567
+    targetPort: apigateway-4567
+  type: ClusterIP
+memcached:
+  ports:
+  - name: memcached
+    port: 11211
+    targetPort: 11211
+  type: ClusterIP
+minio:
+  ports:
+  - name: minio-ui
+    port: 9001
+    targetPort: 9001
+  - name: minio-data
+    port: 9000
+    targetPort: 9000
+  - name: minio-data2
+    port: 80
+    targetPort: 9000
+  type: ClusterIP
+minioConfig:
+  minioUser: zotero
+mysql:
+  ports:
+  - name: mariadb
+    port: 3306
+    targetPort: 3306
+  type: ClusterIP
+phpmyadmin:
+  ports:
+  - name: phpmyadmin
+    port: 80
+    targetPort: 80
+  type: ClusterIP
+pvc:
+  dataminio:
+    storageClass: standard-rwo
+    storageRequest: 10Gi
+redis:
+  ports:
+  - name: redis
+    port: 6379
+    targetPort: 6379
+  type: ClusterIP
+redisConfig:
+  redisConf: |-
+    cluster-enabled no
+    appendonly yes
+    protected-mode no
+    dir /data
+    port 6379
+redisProbes:
+  livenessSh: |-
+    #!/bin/sh
+    pingResponse="$(redis-cli -h localhost ping | head -n1 | awk '{print $1;}')"
+    if [ "$?" -eq "124" ]; then
+      echo "PING timed out"
+      exit 1
+    fi
+    if [ "$pingResponse" != "PONG"] && [ "$pingResponse" != "LOADING" ] && [ "$pingResponse" != "MASTERDOWN" ]; then
+      echo "$pingResponse"
+      exit 1
+    fi
+  readinessSh: |-
+    #!/bin/sh
+    pingResponse="$(redis-cli -h localhost ping)"
+    if [ "$?" -eq "124" ]; then
+      echo "PING timed out"
+      exit 1
+    fi
+    if [ "$pingResponse" != "PONG"]; then
+      echo "$pingResponse"
+      exit 1
+    fi
+streamserver:
+  ports:
+  - name: streamserver
+    port: 81
+    targetPort: 81
+  type: ClusterIP
+tinymceclean:
+  ports:
+  - name: tinymceclean
+    port: 16342
+    targetPort: 16342
+  type: ClusterIP
+zotprimeDb:
+  mariadb:
+    args:
+    - set -o allexport && source tmp/_key/secret-db.txt && set +o allexport && /usr/local/bin/docker-entrypoint.sh
+      mysqld
+    containerSecurityContext:
+      runAsGroup: 999
+      runAsUser: 999
+    image:
+      repository: localhost:32000/zotprime-k8s/zotprime-db
+      tag: latest
+    resources:
+      limits:
+        memory: 512Mi
+      requests:
+        cpu: 500m
+        memory: 512Mi
+  replicas: 1
+  takeDataDirOwnership:
+    image:
+      repository: alpine
+      tag: "3"
+  volumeClaims:
+    datadb:
+      requests:
+        storage: 2Gi
+zotprimeElasticsearch:
+  elasticsearch:
+    env:
+      clusterName: zotero
+      clusterRoutingAllocationDiskThresholdEnabled: "false"
+      discoveryType: single-node
+      xpackSecurityEnabled: "true"
+    image:
+      repository: localhost:32000/zotprime-k8s/zotprime-elasticsearch
+      tag: latest
+    resources:
+      limits:
+        memory: 1536Mi
+      requests:
+        cpu: "1"
+        memory: 1536Mi
+  initSysctl:
+    containerSecurityContext:
+      privileged: true
+    image:
+      repository: busybox
+      tag: 1.27.2
+  replicas: 1
+  volumeClaims:
+    dataes:
+      requests:
+        storage: 2Gi
+zotprimeLocalstack:
+  localstack:
+    containerSecurityContext: {}
+    env:
+      debug: "0"
+      localstackK8SServiceName: localstack
+      services: sns,sqs,apigateway
+    image:
+      repository: localhost:32000/zotprime-k8s/zotprime-localstack
+      tag: latest
+    imagePullPolicy: IfNotPresent
+    resources:
+      limits:
+        memory: 256Mi
+      requests:
+        cpu: 200m
+        memory: 256Mi
+  replicas: 1
+zotprimeMemcached:
+  memcached:
+    args:
+    - -m 2047
+    - -I 50M
+    image:
+      repository: localhost:32000/zotprime-k8s/zotprime-memcached
+      tag: latest
+    resources:
+      limits:
+        memory: 1536Mi
+      requests:
+        cpu: 200m
+        memory: 1536Mi
+  replicas: 1
+zotprimeMinio:
+  replicas: 1
+  zotprimeMinio:
+    args:
+    - set -o allexport && source tmp/_key/secret-minio.txt && set +o allexport && minio
+      server /data --console-address :9001
+    image:
+      repository: localhost:32000/zotprime-k8s/zotprime-minio
+      tag: latest
+    imagePullPolicy: Always
+    resources:
+      limits:
+        memory: 512Mi
+      requests:
+        cpu: 500m
+        memory: 512Mi
+  zotprimeMiniomc:
+    image:
+      repository: localhost:32000/zotprime-k8s/zotprime-miniomc
+      tag: latest
+    resources:
+      limits:
+        memory: 256Mi
+      requests:
+        cpu: 200m
+        memory: 256Mi
+zotprimePhpmyadmin:
+  phpmyadmin:
+    env:
+      pmaHost: mysql
+      pmaPort: "3306"
+    image:
+      repository: localhost:32000/zotprime-k8s/zotprime-phpmyadmin
+      tag: latest
+    resources:
+      limits:
+        memory: 256Mi
+      requests:
+        cpu: 100m
+        memory: 256Mi
+zotprimeRedis:
+  redis:
+    args:
+    - /conf/redis.conf
+    - --protected-mode
+    - "no"
+    image:
+      repository: localhost:32000/zotprime-k8s/zotprime-redis
+      tag: latest
+    resources:
+      limits:
+        memory: 100Mi
+      requests:
+        cpu: 100m
+        memory: 100Mi
+  replicas: 1
+  volumeClaims:
+    dataredis:
+      requests:
+        storage: 1Gi
+zotprimeStreamserver:
+  replicas: 1
+  zotprimeStreamserver:
+    image:
+      repository: localhost:32000/zotprime-k8s/zotprime-streamserver
+      tag: latest
+    resources:
+      limits:
+        memory: 256Mi
+      requests:
+        cpu: "1"
+        memory: 256Mi
+zotprimeTinymceclean:
+  replicas: 1
+  zotprimeTinymceclean:
+    image:
+      repository: localhost:32000/zotprime-k8s/zotprime-tinymceclean
+      tag: latest
+    imagePullPolicy: Always
+    resources:
+      limits:
+        memory: 256Mi
+      requests:
+        cpu: 100m
+        memory: 256Mi
diff --git a/zotprime-k8s/microk8s/manifests/init/init.sh b/zotprime-k8s/microk8s/manifests/init/init.sh
new file mode 100755
index 00000000..d782bd66
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/init/init.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+
+set -eux
+
+cd /var/www/zotero/misc && ./init-mysql.sh
+cd /var/www/zotero/misc && ./db_update.sh
+aws --endpoint-url "http://minio:9000" s3 mb s3://zotero
+aws --endpoint-url "http://minio:9000" s3 mb s3://zotero-fulltext
+aws --endpoint-url "http://localstack:4575" sns create-topic --name zotero
diff --git a/zotprime-k8s/microk8s/manifests/secret-db.yaml_example b/zotprime-k8s/microk8s/manifests/secret-db.yaml_example
new file mode 100644
index 00000000..51f4d168
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/secret-db.yaml_example
@@ -0,0 +1,8 @@
+apiVersion: v1
+data:
+  secret-db.txt: TUFSSUFEQl9ST09UX1BBU1NXT1JEPXpvdGVybwpNQVJJQURCX1BBU1NXT1JEPXpvdGVyb3Rlc3QK
+kind: Secret
+metadata:
+  name: db-secret
+  namespace: zotprime
+type: Opaque
diff --git a/zotprime-k8s/microk8s/manifests/secret-minio.yaml_example b/zotprime-k8s/microk8s/manifests/secret-minio.yaml_example
new file mode 100644
index 00000000..bf99622c
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/secret-minio.yaml_example
@@ -0,0 +1,8 @@
+apiVersion: v1
+data:
+  secret-minio.txt: TUlOSU9fUk9PVF9QQVNTV09SRD16b3Rlcm9kb2NrZXIK
+kind: Secret
+metadata:
+  name: minio-secret
+  namespace: zotprime
+type: Opaque
diff --git a/zotprime-k8s/microk8s/manifests/secret/secret-db.txt_example b/zotprime-k8s/microk8s/manifests/secret/secret-db.txt_example
new file mode 100644
index 00000000..a6773e99
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/secret/secret-db.txt_example
@@ -0,0 +1,2 @@
+MARIADB_ROOT_PASSWORD=zotero
+MARIADB_PASSWORD=zoterotest
diff --git a/zotprime-k8s/microk8s/manifests/secret/secret-minio.txt_example b/zotprime-k8s/microk8s/manifests/secret/secret-minio.txt_example
new file mode 100644
index 00000000..96006eed
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/secret/secret-minio.txt_example
@@ -0,0 +1 @@
+MINIO_ROOT_PASSWORD=zoterodocker
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-dataserver-configmap.yaml b/zotprime-k8s/microk8s/manifests/zotprime-dataserver-configmap.yaml
new file mode 100644
index 00000000..75e86b7a
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-dataserver-configmap.yaml
@@ -0,0 +1,16 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: dataserver-config
+  namespace: zotprime
+data:
+  init.sh: |
+    #!/bin/sh
+
+    set -eux
+
+    cd /var/www/zotero/misc && ./init-mysql.sh
+    cd /var/www/zotero/misc && ./db_update.sh
+    aws --endpoint-url "http://minio:9000" s3 mb s3://zotero
+    aws --endpoint-url "http://minio:9000" s3 mb s3://zotero-fulltext
+    aws --endpoint-url "http://localstack:4575" sns create-topic --name zotero
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-dataserver-deployment.yaml b/zotprime-k8s/microk8s/manifests/zotprime-dataserver-deployment.yaml
new file mode 100644
index 00000000..84e878b4
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-dataserver-deployment.yaml
@@ -0,0 +1,74 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    apps: zotprime-dataserver
+  name: zotprime-dataserver
+  namespace: zotprime
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-dataserver
+  strategy: {}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-dataserver
+      name: zotprime-dataserver
+    spec:
+      containers:
+        - image: uniuu/zotprime-dataserver:2.6.1-rc
+          imagePullPolicy: Always
+          name: zotprime-dataserver
+          ports:
+            - containerPort: 80
+#            - containerPort: 8082
+          env:
+            - name: DSURI
+              value: http://api.projectdev.net/
+            - name: S3POINTURI
+              value: s3min.projectdev.net
+          lifecycle:
+            postStart:
+              exec:
+                command: [ "/bin/sh", "-xc", 'sleep 120; bash /tmp/_conf/init.sh ; exit 0' ]
+          livenessProbe:
+            httpGet:
+              path: /
+              port: 80
+            initialDelaySeconds: 160
+            periodSeconds: 30
+#            timeoutSeconds: 5
+          readinessProbe:
+            failureThreshold: 3
+            initialDelaySeconds: 150
+            periodSeconds: 30
+            successThreshold: 1
+            timeoutSeconds: 1
+            httpGet:
+              path: /
+              port: 80
+          resources:
+            limits:
+              memory: 512Mi
+#              cpu: "0.3"
+            requests:
+              memory: 512Mi
+              cpu: "1"
+          volumeMounts:
+            - name: dataserver-config
+              mountPath: "/tmp/_conf"
+              readOnly: true
+      priorityClassName: low-priority
+      restartPolicy: Always
+      hostAliases:
+        - ip: "10.30.11.11"
+          hostnames:
+          - "s3min.projectdev.net"
+      securityContext: {}
+      volumes:
+        - name: dataserver-config
+          configMap:
+            name: dataserver-config
+status: {}
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-dataserver-service.yaml b/zotprime-k8s/microk8s/manifests/zotprime-dataserver-service.yaml
new file mode 100644
index 00000000..adc3ceee
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-dataserver-service.yaml
@@ -0,0 +1,20 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: dataserver
+  name: dataserver
+  namespace: zotprime
+spec:
+  ports:
+    - name: dataserver-api
+      port: 80
+      targetPort: 80
+#    - name: s3
+#      port: 8082
+#      targetPort: 8082
+  selector:
+    apps: zotprime-dataserver
+  type: ClusterIP
+status:
+  loadBalancer: {}
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-db-configmap.yaml b/zotprime-k8s/microk8s/manifests/zotprime-db-configmap.yaml
new file mode 100644
index 00000000..2ea61ce3
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-db-configmap.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: db-config
+  namespace: zotprime
+data:
+  mariadb-user: zotero
+  mariadb-databasename: zoterotest
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-db-service.yaml b/zotprime-k8s/microk8s/manifests/zotprime-db-service.yaml
new file mode 100644
index 00000000..e9309147
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-db-service.yaml
@@ -0,0 +1,18 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: mysql
+  namespace: zotprime
+  annotations:
+    service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
+  labels:
+    apps: mysql
+spec:
+  ports:
+  - port: 3306
+    targetPort: 3306
+    name: mariadb
+  selector:
+    apps: zotprime-db
+  clusterIP: None
+#  publishNotReadyAddresses: True
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-db-statefulset.yaml b/zotprime-k8s/microk8s/manifests/zotprime-db-statefulset.yaml
new file mode 100644
index 00000000..e8387bfb
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-db-statefulset.yaml
@@ -0,0 +1,83 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: zotprime-db
+  namespace: zotprime
+  labels:
+    apps: zotprime-db
+spec:
+  serviceName: "db-service"
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-db
+  template:
+    metadata:
+      labels:
+        apps: zotprime-db
+    spec:
+      initContainers:
+      - name: take-data-dir-ownership
+        image: alpine:3
+        # Give `mysql` user permissions a mounted volume
+        # https://stackoverflow.com/a/51195446/4360433
+        command:
+        - chown
+        - -R
+        - 999:999
+        - /var/lib/mysql
+        volumeMounts:
+        - name: datadb
+          mountPath: /var/lib/mysql 
+      containers:
+      - name: mariadb
+        image: uniuu/zotprime-db:2.6.1-rc
+        ports:
+        - containerPort: 3306
+          name: mariadb-port
+        resources:
+          limits:
+            memory: 512Mi
+#            cpu: "1"
+#            cpu: "0.5"
+          requests:
+            memory: 512Mi
+            cpu: "0.5"
+        command:
+          - /bin/bash
+          - -c
+        args: 
+          -  set -o allexport && source tmp/_key/secret-db.txt && set +o allexport && /usr/local/bin/docker-entrypoint.sh mysqld         
+        env:
+        - name: MARIADB_USER
+          valueFrom:
+            configMapKeyRef:
+              name: db-config
+              key: mariadb-user
+        - name: MARIADB_DATABASE
+          valueFrom:
+            configMapKeyRef:
+              name: db-config
+              key: mariadb-databasename
+        volumeMounts:
+        - name: datadb
+          mountPath: /var/lib/mysql/
+        - name: db-secret
+          mountPath: tmp/_key/
+#          subPath: _key/
+        securityContext:
+          runAsUser: 999
+          runAsGroup: 999
+      volumes:
+        - name: db-secret
+          secret:
+            secretName: db-secret
+      priorityClassName: high-priority
+  volumeClaimTemplates:
+  - metadata:
+      name: datadb
+    spec:
+      accessModes: [ "ReadWriteOnce" ]
+      resources:
+        requests:
+          storage: 2Gi
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-elasticsearch-service.yaml b/zotprime-k8s/microk8s/manifests/zotprime-elasticsearch-service.yaml
new file mode 100644
index 00000000..530f6061
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-elasticsearch-service.yaml
@@ -0,0 +1,15 @@
+
+apiVersion: v1
+kind: Service
+metadata:
+  name: elasticsearch
+  namespace: zotprime
+  labels:
+    apps: elasticsearch
+spec:
+  ports:
+  - port: 9300
+    name: elasticsearch
+  clusterIP: None
+  selector:
+    apps: zotprime-elasticsearch
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-elasticsearch-statefulset.yaml b/zotprime-k8s/microk8s/manifests/zotprime-elasticsearch-statefulset.yaml
new file mode 100644
index 00000000..40381684
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-elasticsearch-statefulset.yaml
@@ -0,0 +1,98 @@
+
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: zotprime-elasticsearch
+  namespace: zotprime
+  labels:
+#    component: elasticsearch
+    apps: zotprime-elasticsearch
+spec:
+  selector:
+    matchLabels:
+      apps: zotprime-elasticsearch
+  serviceName: "elasticsearch-masterservice"
+  replicas: 1
+  template:
+    metadata:
+      labels:
+#        component: elasticsearch
+        apps: zotprime-elasticsearch
+    spec:
+      initContainers:
+      - name: init-sysctl
+        image: busybox:1.27.2
+        command:
+        - sysctl
+        - -w
+        - vm.max_map_count=262144
+        securityContext:
+          privileged: true
+      containers:
+      - name: elasticsearch
+        image: uniuu/zotprime-elasticsearch:2.6.1-rc
+        env:
+        - name: CLUSTER_NAME
+          value: zotero
+        - name: xpack.security.enabled
+          value: "true"
+        - name: cluster.routing.allocation.disk.threshold_enabled
+          value: "false"
+        - name: discovery.type
+          value: single-node
+        - name: NAMESPACE
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.namespace
+#        - name: NODE_NAME
+#          valueFrom:
+#            fieldRef:
+#              fieldPath: metadata.name
+#        - name: NUMBER_OF_MASTERS
+#          value: "2"
+#        - name: NODE_MASTER
+#          value: "true"
+#        - name: NODE_INGEST
+#          value: "false"
+#        - name: NODE_DATA
+#          value: "false"
+#        - name: HTTP_ENABLE
+#          value: "false"
+#        - name: ES_JAVA_OPTS
+#          value: -Xms256m -Xmx256m
+        - name: PROCESSORS
+          valueFrom:
+            resourceFieldRef:
+              resource: limits.cpu
+        resources:
+          limits:
+#            cpu: "0.5"
+#            cpu: "1"
+#            memory: 2Gi
+            memory: 2Gi
+          requests:
+            cpu: "1"
+#            cpu: "0.25"
+#            memory: 1500Mi
+            memory: 2Gi
+        ports:
+        - containerPort: 9300
+          name: transport
+#        livenessProbe:
+#          tcpSocket:
+#            port: transport
+#          initialDelaySeconds: 120
+#          periodSeconds: 10
+        volumeMounts:
+        - name: dataes
+          mountPath: /data
+      priorityClassName: high-priority
+  volumeClaimTemplates:
+  - metadata:
+      name: dataes
+    spec:
+#      storageClassName: standard
+      accessModes: [ ReadWriteOnce ]
+      resources:
+        requests:
+          storage: 2Gi
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-ingress-http.yaml b/zotprime-k8s/microk8s/manifests/zotprime-ingress-http.yaml
new file mode 100644
index 00000000..10a9b382
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-ingress-http.yaml
@@ -0,0 +1,55 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  labels:
+    apps: zotprime-ingress-http
+  name: zotprime-ingress-http
+  namespace: zotprime
+  annotations:
+    nginx.ingress.kubernetes.io/rewrite-target: /
+spec:
+  rules:
+    - host: min.zotprime
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: minio
+                port:
+                  name: minio-ui
+    - host: s3min.zotprime
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: minio
+                port:
+                  name: minio-data
+    - host: pm.zotprime
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: phpmyadmin
+                port:
+#                  name: phpmyadmin
+                  number: 80
+    - host: api.zotprime
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: dataserver
+                port:
+                  name: dataserver-api
+#                  number: 8080
+#status:
+#  loadBalancer: {}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-ingress-websocket.yaml b/zotprime-k8s/microk8s/manifests/zotprime-ingress-websocket.yaml
new file mode 100644
index 00000000..0c91185f
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-ingress-websocket.yaml
@@ -0,0 +1,42 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  labels:
+    apps: zotprime-ingress-websocket
+  name: zotprime-ingress-websocket
+  namespace: zotprime
+  annotations:
+    nginx.ingress.kubernetes.io/rewrite-target: /
+    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
+    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
+#    nginx.ingress.kubernetes.io/configuration-snippet: |
+#       proxy_http_version 1.1;
+#       proxy_set_header Upgrade "websocket";
+#       proxy_set_header Connection "Upgrade";
+    nginx.ingress.kubernetes.io/server-snippets: |
+      location / {
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_http_version 1.1;
+        proxy_set_header X-Forwarded-Host $http_host;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Forwarded-For $remote_addr;
+        proxy_set_header Host $host;
+        proxy_set_header Connection "upgrade";
+        proxy_cache_bypass $http_upgrade;
+      }
+spec:
+  rules:
+    - host: stream.zotprime
+      http:
+        paths:
+          - backend:
+              service:
+                name: streamserver
+                port:
+                  name: streamserver
+#                  number: 8081
+#            pathType: Exact
+            path: /
+            pathType: Prefix
+#status:
+#  loadBalancer: {}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-localstack-deployment.yaml b/zotprime-k8s/microk8s/manifests/zotprime-localstack-deployment.yaml
new file mode 100644
index 00000000..76d010a2
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-localstack-deployment.yaml
@@ -0,0 +1,245 @@
+---
+# Source: localstack/templates/deployment.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: zotprime-localstack
+  namespace: "zotprime"
+  labels:
+    apps: zotprime-localstack
+#  annotations:
+    
+spec:
+  replicas: 1
+  strategy:
+    type: RollingUpdate
+  selector:
+    matchLabels:
+      apps: zotprime-localstack
+      
+  template:
+    metadata:
+      labels:
+        apps: zotprime-localstack
+    spec:
+#      serviceAccountName: localstack
+      securityContext:
+        {}
+      containers:
+        - name: localstack
+          securityContext:
+            {}
+          image: uniuu/zotprime-localstack:2.6.1-rc
+          imagePullPolicy: IfNotPresent
+          ports:
+            - name: edge
+              containerPort: 4566
+              protocol: TCP
+            - name: "sns-4575"
+              containerPort: 4575
+              protocol: TCP
+            - name: "sqs-4576"
+              containerPort: 4576
+              protocol: TCP
+            - name: "apigateway-4567"
+              containerPort: 4567
+              protocol: TCP
+            - name: "ext-svc-4510"
+              containerPort: 4510
+              protocol: TCP
+            - name: "ext-svc-4511"
+              containerPort: 4511
+              protocol: TCP
+            - name: "ext-svc-4512"
+              containerPort: 4512
+              protocol: TCP
+            - name: "ext-svc-4513"
+              containerPort: 4513
+              protocol: TCP
+            - name: "ext-svc-4514"
+              containerPort: 4514
+              protocol: TCP
+            - name: "ext-svc-4515"
+              containerPort: 4515
+              protocol: TCP
+            - name: "ext-svc-4516"
+              containerPort: 4516
+              protocol: TCP
+            - name: "ext-svc-4517"
+              containerPort: 4517
+              protocol: TCP
+            - name: "ext-svc-4518"
+              containerPort: 4518
+              protocol: TCP
+            - name: "ext-svc-4519"
+              containerPort: 4519
+              protocol: TCP
+            - name: "ext-svc-4520"
+              containerPort: 4520
+              protocol: TCP
+            - name: "ext-svc-4521"
+              containerPort: 4521
+              protocol: TCP
+            - name: "ext-svc-4522"
+              containerPort: 4522
+              protocol: TCP
+            - name: "ext-svc-4523"
+              containerPort: 4523
+              protocol: TCP
+            - name: "ext-svc-4524"
+              containerPort: 4524
+              protocol: TCP
+            - name: "ext-svc-4525"
+              containerPort: 4525
+              protocol: TCP
+            - name: "ext-svc-4526"
+              containerPort: 4526
+              protocol: TCP
+            - name: "ext-svc-4527"
+              containerPort: 4527
+              protocol: TCP
+            - name: "ext-svc-4528"
+              containerPort: 4528
+              protocol: TCP
+            - name: "ext-svc-4529"
+              containerPort: 4529
+              protocol: TCP
+            - name: "ext-svc-4530"
+              containerPort: 4530
+              protocol: TCP
+            - name: "ext-svc-4531"
+              containerPort: 4531
+              protocol: TCP
+            - name: "ext-svc-4532"
+              containerPort: 4532
+              protocol: TCP
+            - name: "ext-svc-4533"
+              containerPort: 4533
+              protocol: TCP
+            - name: "ext-svc-4534"
+              containerPort: 4534
+              protocol: TCP
+            - name: "ext-svc-4535"
+              containerPort: 4535
+              protocol: TCP
+            - name: "ext-svc-4536"
+              containerPort: 4536
+              protocol: TCP
+            - name: "ext-svc-4537"
+              containerPort: 4537
+              protocol: TCP
+            - name: "ext-svc-4538"
+              containerPort: 4538
+              protocol: TCP
+            - name: "ext-svc-4539"
+              containerPort: 4539
+              protocol: TCP
+            - name: "ext-svc-4540"
+              containerPort: 4540
+              protocol: TCP
+            - name: "ext-svc-4541"
+              containerPort: 4541
+              protocol: TCP
+            - name: "ext-svc-4542"
+              containerPort: 4542
+              protocol: TCP
+            - name: "ext-svc-4543"
+              containerPort: 4543
+              protocol: TCP
+            - name: "ext-svc-4544"
+              containerPort: 4544
+              protocol: TCP
+            - name: "ext-svc-4545"
+              containerPort: 4545
+              protocol: TCP
+            - name: "ext-svc-4546"
+              containerPort: 4546
+              protocol: TCP
+            - name: "ext-svc-4547"
+              containerPort: 4547
+              protocol: TCP
+            - name: "ext-svc-4548"
+              containerPort: 4548
+              protocol: TCP
+            - name: "ext-svc-4549"
+              containerPort: 4549
+              protocol: TCP
+            - name: "ext-svc-4550"
+              containerPort: 4550
+              protocol: TCP
+            - name: "ext-svc-4551"
+              containerPort: 4551
+              protocol: TCP
+            - name: "ext-svc-4552"
+              containerPort: 4552
+              protocol: TCP
+            - name: "ext-svc-4553"
+              containerPort: 4553
+              protocol: TCP
+            - name: "ext-svc-4554"
+              containerPort: 4554
+              protocol: TCP
+            - name: "ext-svc-4555"
+              containerPort: 4555
+              protocol: TCP
+            - name: "ext-svc-4556"
+              containerPort: 4556
+              protocol: TCP
+            - name: "ext-svc-4557"
+              containerPort: 4557
+              protocol: TCP
+            - name: "ext-svc-4558"
+              containerPort: 4558
+              protocol: TCP
+            - name: "ext-svc-4559"
+              containerPort: 4559
+              protocol: TCP
+#          livenessProbe:
+#            failureThreshold: 3
+#            initialDelaySeconds: 0
+#            periodSeconds: 10
+#            successThreshold: 1
+#            timeoutSeconds: 1
+#            httpGet:
+#              path: /_localstack/health
+#              port: edge
+#          readinessProbe:
+#            failureThreshold: 3
+#            initialDelaySeconds: 0
+#            periodSeconds: 10
+#            successThreshold: 1
+#            timeoutSeconds: 1
+#            httpGet:
+#              path: /_localstack/health
+#              port: edge
+          resources:
+            limits:
+              memory: 256Mi
+#              cpu: "0.1"
+            requests:
+              memory: 256Mi
+              cpu: "0.2"
+          env:
+            - name: SERVICES
+              value: "sns,sqs,apigateway"
+            - name: DEBUG
+              value: "0"
+#            - name: EXTERNAL_SERVICE_PORTS_START
+#              value: "4510"
+#            - name: EXTERNAL_SERVICE_PORTS_END
+#              value: "4560"
+            - name: LOCALSTACK_K8S_SERVICE_NAME
+              value: localstack
+            - name: LOCALSTACK_K8S_NAMESPACE
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.namespace
+#            - name: LAMBDA_RUNTIME_EXECUTOR
+#              value: "docker"
+#            - name: LAMBDA_K8S_IMAGE_PREFIX
+#              value: "localstack/lambda-"
+#            - name: LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT
+#              value: "60"
+#            - name: OVERRIDE_IN_DOCKER
+#              value: "1"
+#      volumes:
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-localstack-service.yaml b/zotprime-k8s/microk8s/manifests/zotprime-localstack-service.yaml
new file mode 100644
index 00000000..e247ed88
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-localstack-service.yaml
@@ -0,0 +1,27 @@
+---
+# Source: localstack/templates/service.yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: localstack
+  namespace: "zotprime"
+  labels:
+    apps: localstack
+#  annotations:
+    
+spec:
+#  type: NodePort
+#  externalTrafficPolicy: ""
+  ports:
+    - name: "localstack-sns"
+      port: 4575
+      targetPort: "sns-4575"
+#      nodePort: 31566
+    - name: "localstack-sqs"
+      port: 4576
+      targetPort: "sqs-4576"
+    - name: "localstack-apigateway"
+      port: 4567
+      targetPort: "apigateway-4567"
+  selector:
+    apps: zotprime-localstack
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-memcached-deployment.yaml b/zotprime-k8s/microk8s/manifests/zotprime-memcached-deployment.yaml
new file mode 100644
index 00000000..4bbd3807
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-memcached-deployment.yaml
@@ -0,0 +1,40 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: zotprime-memcached
+  namespace: zotprime
+  labels:
+    apps: zotprime-memcached
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-memcached
+  template:
+    metadata:
+      name: memcached
+      labels:
+        apps: zotprime-memcached
+    spec:
+      containers:
+      - name: memcached
+        image: uniuu/zotprime-memcached:2.6.1-rc
+        resources:
+          limits:
+#            cpu: 200m
+            memory: 2000Mi
+          requests:
+            cpu: 200m
+            memory: 2000Mi
+        ports:
+          - containerPort: 11211
+            protocol: TCP
+        args:
+          - -m 2047
+          - -I 50M
+      priorityClassName: high-priority
+#      - name: memcached-exporter
+#        image: prom/memcached-exporter
+#        ports:
+#          - containerPort: 9150
+#            protocol: TCP
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-memcached-service.yaml b/zotprime-k8s/microk8s/manifests/zotprime-memcached-service.yaml
new file mode 100644
index 00000000..5127d670
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-memcached-service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: memcached
+  name: memcached
+  namespace: zotprime
+spec:
+  ports:
+    - name: memcached
+      port: 11211
+      targetPort: 11211
+  selector:
+    apps: zotprime-memcached
+  type: ClusterIP
+status:
+  loadBalancer: {}
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-minio-configmap.yaml b/zotprime-k8s/microk8s/manifests/zotprime-minio-configmap.yaml
new file mode 100644
index 00000000..fb07fb61
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-minio-configmap.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: minio-config
+  namespace: zotprime
+data:
+  minio-user: zotero
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-minio-deployment.yaml b/zotprime-k8s/microk8s/manifests/zotprime-minio-deployment.yaml
new file mode 100644
index 00000000..2bc93fec
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-minio-deployment.yaml
@@ -0,0 +1,120 @@
+# The `spec.containers[0].args` contains the command run on the pod
+# The `/data` directory corresponds to the `spec.containers[0].volumeMounts[0].mountPath`
+# That mount path corresponds to a Kubernetes HostPath which binds `/data` to a local drive or volume on the worker node where the pod runs
+# 
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    apps: zotprime-minio
+  name: zotprime-minio
+  namespace: zotprime # Change this value to match the namespace metadata.name
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-minio
+  strategy: {}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-minio
+      name: zotprime-minio
+    spec:
+      containers:
+        - name: zotprime-minio
+          image: uniuu/zotprime-minio:2.6.1-rc
+          imagePullPolicy: Always
+          resources:
+            limits:
+              memory: 512Mi
+#              cpu: "0.5"
+            requests:
+              memory: 512Mi
+              cpu: "0.5"
+          command:
+            - /bin/bash
+            - -c
+          args: 
+            -  set -o allexport && source tmp/_key/secret-minio.txt && set +o allexport && minio server /data --console-address :9001 
+#| tee /data/test.log
+#    lifecycle:
+#      preStop:
+#        exec:
+#          command:
+#            - /bin/sh
+#            - -c
+#          args: ls -lha /tmp/_key/
+          livenessProbe:
+            httpGet:
+              path: /minio/health/live
+              port: 9000
+            initialDelaySeconds: 10
+            periodSeconds: 30
+            timeoutSeconds: 20
+          readinessProbe:
+            failureThreshold: 3
+            initialDelaySeconds: 0
+            periodSeconds: 20
+            successThreshold: 1
+            timeoutSeconds: 1
+            httpGet:
+              path: /minio/health/live
+              port: 9000
+          ports:
+            - containerPort: 9000
+              name: data
+            - containerPort: 9001
+              name: ui
+          env:
+            - name: MINIO_ROOT_USER
+              valueFrom:
+                 configMapKeyRef:
+                   name: minio-config
+                   key: minio-user
+          volumeMounts:
+            - name: s3data # Corresponds to the `spec.volumes` Persistent Volume
+              mountPath: /data
+            - name: minio-secret
+              mountPath: "/tmp/_key"
+#             subPath: secret.txt
+              readOnly: true
+        - name: zotprime-miniomc
+          image: uniuu/zotprime-miniomc:2.6.1-rc
+          resources:
+            limits:
+              memory: 256Mi
+#              cpu: "0.2"
+            requests:
+              memory: 256Mi
+              cpu: "0.2"
+          volumeMounts:
+            - name: s3data # Corresponds to the `spec.volumes` Persistent Volume
+              mountPath: /data
+            - name: minio-secret
+              mountPath: "/tmp/_key"
+              readOnly: true
+          env:
+            - name: MINIO_ROOT_USER
+              valueFrom:
+                configMapKeyRef:
+                  name: minio-config
+                  key: minio-user
+#      nodeSelector:
+#        kubernetes.io/hostname: localhost.localdomain # Specify a node label associated to the Worker Node on which you want to deploy the pod.
+#  terminationGracePeriodSeconds: 30
+      priorityClassName: high-priority
+      restartPolicy: Always
+      securityContext: {}
+      volumes:
+        - name: s3data
+          persistentVolumeClaim:
+            claimName: dataminio
+#        - name: bucketsstore
+#          hostPath: # MinIO generally recommends using locally-attached volumes
+#            path: /mnt/disk1/data # Specify a path to a local drive or volume on the Kubernetes worker node
+#            type: DirectoryOrCreate # The path to the last directory must exist
+        - name: minio-secret
+          secret:
+            secretName: minio-secret
+status: {}
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-minio-persistentvolumeclaim.yaml b/zotprime-k8s/microk8s/manifests/zotprime-minio-persistentvolumeclaim.yaml
new file mode 100644
index 00000000..8ceb03b7
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-minio-persistentvolumeclaim.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+  name: dataminio
+  namespace: zotprime
+spec:
+  accessModes:
+    - ReadWriteOnce
+  resources:
+    requests:
+      storage: 10Gi
+  storageClassName: standard-rwo
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-minio-service.yaml b/zotprime-k8s/microk8s/manifests/zotprime-minio-service.yaml
new file mode 100644
index 00000000..fa6eb739
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-minio-service.yaml
@@ -0,0 +1,24 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: minio
+  name: minio
+  namespace: zotprime
+spec:
+  clusterIP: 10.152.183.11
+  ports:
+    - name: minio-ui
+      port: 9001
+      targetPort: 9001
+    - name: minio-data
+      port: 9000
+      targetPort: 9000
+    - name: minio-data2
+      port: 80
+      targetPort: 9000
+  selector:
+    apps: zotprime-minio
+  type: ClusterIP
+status:
+  loadBalancer: {}
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-phpmyadmin-deployment.yaml b/zotprime-k8s/microk8s/manifests/zotprime-phpmyadmin-deployment.yaml
new file mode 100644
index 00000000..554b2bea
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-phpmyadmin-deployment.yaml
@@ -0,0 +1,44 @@
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: zotprime-phpmyadmin
+  namespace: zotprime
+  labels:
+    apps: zotprime-phpmyadmin
+spec:
+  selector:
+    matchLabels:
+      apps: zotprime-phpmyadmin
+  template:
+    metadata:
+      labels:
+        apps: zotprime-phpmyadmin
+    spec:
+      containers:
+        - name: phpmyadmin
+          image: uniuu/zotprime-phpmyadmin:2.6.1-rc
+          ports:
+            - containerPort: 80
+          resources:
+            limits:
+              memory: 256Mi
+#              cpu: "0.1"
+            requests:
+              memory: 256Mi
+              cpu: "0.1"
+          env:
+            - name: PMA_HOST
+              value: mysql
+            - name: PMA_PORT
+              value: "3306"
+#            - name: PMA_ARBITRARY
+#              value: "1"
+#        - name: PMA_ABSOLUTE_URI
+#          value: [uri]
+
+#        - name: MYSQL_ROOT_PASSWORD
+#          valueFrom:
+#            secretKeyRef:
+#              name: mysql-secret
+#              key: root_password
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-phpmyadmin-service.yaml b/zotprime-k8s/microk8s/manifests/zotprime-phpmyadmin-service.yaml
new file mode 100644
index 00000000..d1d3fae6
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-phpmyadmin-service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: phpmyadmin
+  name: phpmyadmin
+  namespace: zotprime
+spec:
+  ports:
+    - name: phpmyadmin
+      port: 80
+      targetPort: 80
+  selector:
+    apps: zotprime-phpmyadmin
+  type: ClusterIP
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-priorityclass-high.yaml b/zotprime-k8s/microk8s/manifests/zotprime-priorityclass-high.yaml
new file mode 100644
index 00000000..2fc9e7da
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-priorityclass-high.yaml
@@ -0,0 +1,8 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: high-priority
+  namespace: zotprime
+value: 1000000 
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-priorityclass-low.yaml b/zotprime-k8s/microk8s/manifests/zotprime-priorityclass-low.yaml
new file mode 100644
index 00000000..81e69741
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-priorityclass-low.yaml
@@ -0,0 +1,8 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: low-priority
+  namespace: zotprime
+value: 800000 
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-priorityclass-medium.yaml b/zotprime-k8s/microk8s/manifests/zotprime-priorityclass-medium.yaml
new file mode 100644
index 00000000..f12dad40
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-priorityclass-medium.yaml
@@ -0,0 +1,8 @@
+apiVersion: scheduling.k8s.io/v1
+kind: PriorityClass
+metadata:
+  name: medium-priority
+  namespace: zotprime
+value: 900000 
+globalDefault: false
+preemptionPolicy: PreemptLowerPriority
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-redis-configmap.yaml b/zotprime-k8s/microk8s/manifests/zotprime-redis-configmap.yaml
new file mode 100644
index 00000000..44bd7c59
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-redis-configmap.yaml
@@ -0,0 +1,44 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: redis-config
+  namespace: zotprime
+data:
+  redis.conf:  |+
+    cluster-enabled no
+    appendonly yes
+    protected-mode no
+    dir /data
+    port 6379
+
+# cluster-node-timeout 15000
+# cluster-config-file /data/nodes.conf
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: redis-probes
+  namespace: zotprime
+data:
+  readiness.sh: |-
+    #!/bin/sh
+    pingResponse="$(redis-cli -h localhost ping)"
+    if [ "$?" -eq "124" ]; then
+      echo "PING timed out"
+      exit 1
+    fi
+    if [ "$pingResponse" != "PONG"]; then
+      echo "$pingResponse"
+      exit 1
+    fi
+  liveness.sh: |-
+    #!/bin/sh
+    pingResponse="$(redis-cli -h localhost ping | head -n1 | awk '{print $1;}')"
+    if [ "$?" -eq "124" ]; then
+      echo "PING timed out"
+      exit 1
+    fi
+    if [ "$pingResponse" != "PONG"] && [ "$pingResponse" != "LOADING" ] && [ "$pingResponse" != "MASTERDOWN" ]; then
+      echo "$pingResponse"
+      exit 1
+    fi
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-redis-service.yaml b/zotprime-k8s/microk8s/manifests/zotprime-redis-service.yaml
new file mode 100644
index 00000000..87b6ef70
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-redis-service.yaml
@@ -0,0 +1,18 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: redis
+  namespace: zotprime
+#  annotations:
+#    service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
+  labels:
+    apps: redis
+spec:
+  ports:
+  - port: 6379
+    targetPort: 6379
+    name: redis
+  selector:
+    apps: zotprime-redis
+  clusterIP: None
+#  publishNotReadyAddresses: True
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-redis-statefulset.yaml b/zotprime-k8s/microk8s/manifests/zotprime-redis-statefulset.yaml
new file mode 100644
index 00000000..4527165b
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-redis-statefulset.yaml
@@ -0,0 +1,104 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: zotprime-redis
+  namespace: zotprime
+  labels:
+    apps: zotprime-redis
+spec:
+  serviceName: "redis-service"
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-redis
+  template:
+    metadata:
+      labels:
+        apps: zotprime-redis
+#        appCluster: redis-cluster
+    spec:
+      terminationGracePeriodSeconds: 20
+      affinity:
+        podAntiAffinity:
+          preferredDuringSchedulingIgnoredDuringExecution:
+          - weight: 100
+            podAffinityTerm:
+              labelSelector:
+                matchExpressions:
+                - key: app
+                  operator: In
+                  values:
+                  - redis
+              topologyKey: kubernetes.io/hostname
+      containers:
+      - name: redis
+        image: uniuu/zotprime-redis:2.6.1-rc
+        command:
+          - "redis-server"
+        args:
+          - "/conf/redis.conf"
+          - "--protected-mode"
+          - "no"
+        resources:
+          requests:
+            cpu: "100m"
+            memory: "100Mi"
+          limits:
+#            cpu: "100m"
+            memory: "100Mi"
+        ports:
+            - name: redis
+              containerPort: 6379
+              protocol: "TCP"
+            - name: cluster
+              containerPort: 16379
+              protocol: "TCP"
+        startupProbe:
+          periodSeconds: 5
+          timeoutSeconds: 5
+          successThreshold: 1
+          failureThreshold: 20
+          tcpSocket:
+            port: redis
+        livenessProbe:
+          periodSeconds: 5
+          timeoutSeconds: 5
+          successThreshold: 1
+          failureThreshold: 5
+          exec:
+            command: ["sh", "-c", "/probes/liveness.sh"]
+        readinessProbe:
+          periodSeconds: 5
+          timeoutSeconds: 1
+          successThreshold: 1
+          failureThreshold: 5
+          exec:
+            command: ["sh", "-c", "/probes/readiness.sh"]
+        volumeMounts:
+        - name: conf
+          mountPath: /conf
+          readOnly: false
+        - name: dataredis
+          mountPath: /data
+          readOnly: false
+        - name: probes
+          mountPath: /probes
+          readOnly: true
+      priorityClassName: high-priority
+      volumes:
+      - name: conf
+        configMap:
+          name: redis-config
+          defaultMode: 0755
+      - name: probes
+        configMap:
+          name: redis-probes
+          defaultMode: 0555
+  volumeClaimTemplates:
+  - metadata:
+      name: dataredis
+    spec:
+      accessModes: [ "ReadWriteOnce" ]
+      resources:
+        requests:
+          storage: 1Gi
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-streamserver-backendconfig.yaml b/zotprime-k8s/microk8s/manifests/zotprime-streamserver-backendconfig.yaml
new file mode 100644
index 00000000..9b8b9995
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-streamserver-backendconfig.yaml
@@ -0,0 +1,15 @@
+apiVersion: cloud.google.com/v1
+kind: BackendConfig
+metadata:
+  name: streamserver-backendconfig
+  namespace: zotprime
+spec:
+  timeoutSec: 3600
+#  healthCheck:
+#    checkIntervalSec: 30
+#    timeoutSec: 20
+#    healthyThreshold: 1
+#    unhealthyThreshold: 3
+#    type: HTTP
+#    requestPath: /health
+#    port: 81
\ No newline at end of file
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-streamserver-deployment.yaml b/zotprime-k8s/microk8s/manifests/zotprime-streamserver-deployment.yaml
new file mode 100644
index 00000000..d4d04abc
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-streamserver-deployment.yaml
@@ -0,0 +1,51 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    apps: zotprime-streamserver
+  name: zotprime-streamserver
+  namespace: zotprime
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-streamserver
+  strategy: {}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-streamserver
+      name: zotprime-streamserver
+    spec:
+      containers:
+        - image: uniuu/zotprime-streamserver:2.6.1-rc
+          name: zotprime-streamserver
+          ports:
+            - containerPort: 81           
+          resources:
+            limits:
+              memory: 256Mi
+#              cpu: "0.5"
+            requests:
+              memory: 256Mi
+              cpu: "1"
+          livenessProbe:
+            httpGet:
+              path: /health
+              port: 81
+            initialDelaySeconds: 10
+            periodSeconds: 30
+            timeoutSeconds: 20
+          readinessProbe:
+            failureThreshold: 3
+            initialDelaySeconds: 0
+            periodSeconds: 20
+            successThreshold: 1
+            timeoutSeconds: 1
+            httpGet:
+              path: /health
+              port: 81
+      priorityClassName: medium-priority      
+      restartPolicy: Always
+      securityContext: {}
+status: {}
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-streamserver-service.yaml b/zotprime-k8s/microk8s/manifests/zotprime-streamserver-service.yaml
new file mode 100644
index 00000000..19e85145
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-streamserver-service.yaml
@@ -0,0 +1,21 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: streamserver
+  name: streamserver
+  namespace: zotprime
+  annotations:
+#    beta.cloud.google.com/backend-config: '{"ports": {"81": "config-broker-ws"}}'
+    cloud.google.com/backend-config: '{"default": "streamserver-backendconfig"}'
+spec:
+  ports:
+    - name: streamserver
+      port: 81
+      targetPort: 81
+  selector:
+    apps: zotprime-streamserver
+  type: ClusterIP
+#status:
+#  loadBalancer: {}
+
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-tinymceclean-deployment.yaml b/zotprime-k8s/microk8s/manifests/zotprime-tinymceclean-deployment.yaml
new file mode 100644
index 00000000..6cd05d20
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-tinymceclean-deployment.yaml
@@ -0,0 +1,36 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    apps: zotprime-tinymceclean
+  name: zotprime-tinymceclean
+  namespace: zotprime
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-tinymceclean
+  strategy: {}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-tinymceclean
+      name: zotprime-tinymceclean
+    spec:
+      containers:
+        - image: uniuu/zotprime-tinymceclean:2.6.1-rc
+          imagePullPolicy: Always
+          name: zotprime-tinymceclean
+          ports:
+            - containerPort: 16342
+          resources:
+            limits:
+              memory: 256Mi
+#              cpu: "0.1"
+            requests:
+              memory: 256Mi
+              cpu: "0.1"
+      priorityClassName: medium-priority
+      restartPolicy: Always
+      securityContext: {}
+status: {}
diff --git a/zotprime-k8s/microk8s/manifests/zotprime-tinymceclean-service.yaml b/zotprime-k8s/microk8s/manifests/zotprime-tinymceclean-service.yaml
new file mode 100644
index 00000000..b4eeaac7
--- /dev/null
+++ b/zotprime-k8s/microk8s/manifests/zotprime-tinymceclean-service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: tinymceclean
+  name: tinymceclean
+  namespace: zotprime
+spec:
+  ports:
+    - name: tinymceclean
+      port: 16342
+      targetPort: 16342
+  selector:
+    apps: zotprime-tinymceclean
+  type: ClusterIP
+#status:
+#  loadBalancer: {}
diff --git a/zotprime-k8s/microk8s/scripts/buildandpushimages_multiarch.sh b/zotprime-k8s/microk8s/scripts/buildandpushimages_multiarch.sh
new file mode 100755
index 00000000..5a7ce2db
--- /dev/null
+++ b/zotprime-k8s/microk8s/scripts/buildandpushimages_multiarch.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+#   Copyright IBM Corporation 2021
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+
+# Invoke as ./buildandpush_multiarchimages.sh <registry_url> <registry_namespace> <comma_separated_platforms>
+# Examples:
+# 1) ./buildandpush_multiarchimages.sh
+# 2) ./buildandpush_multiarchimages.sh index.docker.io your_registry_namespace
+# 3) ./buildandpush_multiarchimages.sh quay.io your_quay_username linux/amd64,linux/arm64,linux/s390x
+
+if [[ "$(basename "$PWD")" != 'scripts' ]] ; then
+  echo 'please run this script from the "scripts" directory'
+  exit 1
+fi
+
+cd ../../ # go to the parent directory so that all the relative paths will be correct
+
+REGISTRY_URL=localhost:32000
+REGISTRY_NAMESPACE=zotprime-k8s
+PLATFORMS="linux/amd64,linux/arm64,linux/s390x,linux/ppc64le"
+if [ "$#" -gt 1 ]; then
+  REGISTRY_URL=$1
+  REGISTRY_NAMESPACE=$2
+fi
+if [ "$#" -eq 3 ]; then
+  PLATFORMS=$3
+fi
+# Uncomment the below line if you want to enable login before pushing
+# docker login ${REGISTRY_URL}
+
+echo 'building and pushing image zotprime-dataserver'
+
+docker buildx build --platform ${PLATFORMS} -f ds.Dockerfile  --push --tag ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-dataserver .
+
+
+echo 'building and pushing image zotprime-minio'
+
+docker buildx build --platform ${PLATFORMS} -f minio.Dockerfile  --push --tag ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-minio .
+
+echo 'building and pushing image zotprime-miniomc'
+
+docker buildx build --platform ${PLATFORMS} -f miniomc.Dockerfile  --push --tag ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-miniomc .
+
+echo 'building and pushing image zotprime-tinymceclean'
+
+docker buildx build --platform ${PLATFORMS} -f Dockerfile  --push --tag ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-tinymceclean .
+
+
+echo 'building and pushing image zotprime-streamserver'
+
+docker buildx build --platform ${PLATFORMS} -f Dockerfile  --push --tag ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-streamserver .
+
+
+echo 'done'
diff --git a/zotprime-k8s/microk8s/scripts/buildimages.sh b/zotprime-k8s/microk8s/scripts/buildimages.sh
new file mode 100755
index 00000000..71822880
--- /dev/null
+++ b/zotprime-k8s/microk8s/scripts/buildimages.sh
@@ -0,0 +1,80 @@
+#!/usr/bin/env bash
+#   Copyright IBM Corporation 2021
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+
+# Invoke as ./buildimages.sh <container_runtime>
+# Examples:
+# 1) ./buildimages.sh
+# 2) ./buildimages.sh podman
+
+if [[ "$(basename "$PWD")" != 'scripts' ]] ; then
+  echo 'please run this script from the "scripts" directory'
+  exit 1
+fi
+CONTAINER_RUNTIME=docker
+if [ "$#" -eq 1 ]; then
+    CONTAINER_RUNTIME=$1
+fi
+if [ "${CONTAINER_RUNTIME}" != "docker" ] && [ "${CONTAINER_RUNTIME}" != "podman" ]; then
+   echo 'Unsupported container runtime passed as an argument for building the images: '"${CONTAINER_RUNTIME}"
+   exit 1
+fi
+cd ../../../ # go to the parent directory so that all the relative paths will be correct
+
+echo 'building image zotprime-dataserver'
+
+${CONTAINER_RUNTIME} build -f ds.Dockerfile -t zotprime-dataserver .
+
+echo 'building image zotprime-minio'
+
+${CONTAINER_RUNTIME} build -f minio.Dockerfile -t zotprime-minio .
+
+echo 'building image zotprime-miniomc'
+
+${CONTAINER_RUNTIME} build -f miniomc.Dockerfile -t zotprime-miniomc .
+
+echo 'building image zotprime-tinymceclean'
+
+${CONTAINER_RUNTIME} build -f tmcs.Dockerfile -t zotprime-tinymceclean .
+
+echo 'building image zotprime-streamserver'
+
+${CONTAINER_RUNTIME} build -f sts.Dockerfile -t zotprime-streamserver .
+
+echo 'building image zotprime-db'
+
+${CONTAINER_RUNTIME} build -f db.Dockerfile -t zotprime-db .
+
+echo 'building image zotprime-redis'
+
+${CONTAINER_RUNTIME} build -f r.Dockerfile -t zotprime-redis .
+
+echo 'building image zotprime-localstack'
+
+${CONTAINER_RUNTIME} build -f ls.Dockerfile -t zotprime-localstack .
+
+echo 'building image zotprime-memcached'
+
+${CONTAINER_RUNTIME} build -f m.Dockerfile -t zotprime-memcached .
+
+echo 'building image zotprime-phpmyadmin'
+
+${CONTAINER_RUNTIME} build -f pa.Dockerfile -t zotprime-phpmyadmin .
+
+echo 'building image zotprime-elasticsearch'
+
+${CONTAINER_RUNTIME} build -f es.Dockerfile -t zotprime-elasticsearch .
+cd -
+
+echo 'done'
diff --git a/zotprime-k8s/microk8s/scripts/pushimages.sh b/zotprime-k8s/microk8s/scripts/pushimages.sh
new file mode 100755
index 00000000..69d58022
--- /dev/null
+++ b/zotprime-k8s/microk8s/scripts/pushimages.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+#   Copyright IBM Corporation 2020
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+
+# Invoke as ./pushimages.sh <registry_url> <registry_namespace> <container_runtime>
+# Examples:
+# 1) ./pushimages.sh
+# 2) ./pushimages.sh quay.io your_quay_username
+# 3) ./pushimages.sh index.docker.io your_registry_namespace podman
+
+REGISTRY_URL=localhost:32000
+REGISTRY_NAMESPACE=zotprime-k8s
+CONTAINER_RUNTIME=docker
+if [ "$#" -gt 1 ]; then
+  REGISTRY_URL=$1
+  REGISTRY_NAMESPACE=$2
+fi
+if [ "$#" -eq 3 ]; then
+    CONTAINER_RUNTIME=$3
+fi
+if [ "${CONTAINER_RUNTIME}" != "docker" ] && [ "${CONTAINER_RUNTIME}" != "podman" ]; then
+   echo 'Unsupported container runtime passed as an argument for pushing the images: '"${CONTAINER_RUNTIME}"
+   exit 1
+fi
+# Uncomment the below line if you want to enable login before pushing
+# ${CONTAINER_RUNTIME} login ${REGISTRY_URL}
+
+echo 'pushing image zotprime-streamserver'
+${CONTAINER_RUNTIME} tag zotprime-streamserver ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-streamserver
+${CONTAINER_RUNTIME} push ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-streamserver
+
+echo 'pushing image zotprime-dataserver'
+${CONTAINER_RUNTIME} tag zotprime-dataserver ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-dataserver
+${CONTAINER_RUNTIME} push ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-dataserver
+
+echo 'pushing image zotprime-tinymceclean'
+${CONTAINER_RUNTIME} tag zotprime-tinymceclean ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-tinymceclean
+${CONTAINER_RUNTIME} push ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-tinymceclean
+
+echo 'pushing image db-zotprime-minio'
+${CONTAINER_RUNTIME} tag zotprime-minio ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-minio
+${CONTAINER_RUNTIME} push ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-minio
+
+echo 'pushing image db-zotprime-miniomc'
+${CONTAINER_RUNTIME} tag zotprime-miniomc ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-miniomc
+${CONTAINER_RUNTIME} push ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-miniomc
+
+echo 'pushing image db-zotprime-db'
+${CONTAINER_RUNTIME} tag zotprime-db ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-db
+${CONTAINER_RUNTIME} push ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-db
+
+echo 'pushing image db-zotprime-elasticsearch'
+${CONTAINER_RUNTIME} tag zotprime-elasticsearch ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-elasticsearch
+${CONTAINER_RUNTIME} push ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-elasticsearch
+
+echo 'pushing image db-zotprime-localstack'
+${CONTAINER_RUNTIME} tag zotprime-localstack ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-localstack
+${CONTAINER_RUNTIME} push ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-localstack
+
+echo 'pushing image db-zotprime-memcached'
+${CONTAINER_RUNTIME} tag zotprime-memcached ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-memcached
+${CONTAINER_RUNTIME} push ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-memcached
+
+echo 'pushing image db-zotprime-phpmyadmin'
+${CONTAINER_RUNTIME} tag zotprime-phpmyadmin ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-phpmyadmin
+${CONTAINER_RUNTIME} push ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-phpmyadmin
+
+echo 'pushing image db-zotprime-redis'
+${CONTAINER_RUNTIME} tag zotprime-redis ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-redis
+${CONTAINER_RUNTIME} push ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/zotprime-redis
+echo 'done'
diff --git a/zotprime-k8s/microk8s/zotprime-namespace.yaml b/zotprime-k8s/microk8s/zotprime-namespace.yaml
new file mode 100644
index 00000000..764e2f42
--- /dev/null
+++ b/zotprime-k8s/microk8s/zotprime-namespace.yaml
@@ -0,0 +1,7 @@
+# Deploys a new Namespace for the ZotPrime
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: zotprime
+  labels:
+    name: zotprime
\ No newline at end of file
diff --git a/zotprime-k8s/yamls/dataserver/zotprime-dataserver-deployment.yaml b/zotprime-k8s/yamls/dataserver/zotprime-dataserver-deployment.yaml
new file mode 100644
index 00000000..c14c46d9
--- /dev/null
+++ b/zotprime-k8s/yamls/dataserver/zotprime-dataserver-deployment.yaml
@@ -0,0 +1,31 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  creationTimestamp: null
+  labels:
+    move2kube.konveyor.io/service: zotprime-dataserver
+  name: zotprime-dataserver
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      move2kube.konveyor.io/service: zotprime-dataserver
+  strategy: {}
+  template:
+    metadata:
+      creationTimestamp: null
+      labels:
+        move2kube.konveyor.io/service: zotprime-dataserver
+      name: zotprime-dataserver
+    spec:
+      containers:
+        - image: localhost:32000/zotprime-k8s/zotprime-dataserver:latest
+          imagePullPolicy: Always
+          name: zotprime-dataserver
+          ports:
+            - containerPort: 80
+            - containerPort: 8082
+          resources: {}
+      restartPolicy: Always
+      securityContext: {}
+status: {}
diff --git a/zotprime-k8s/yamls/dataserver/zotprime-dataserver-service.yaml b/zotprime-k8s/yamls/dataserver/zotprime-dataserver-service.yaml
new file mode 100644
index 00000000..f08e60a1
--- /dev/null
+++ b/zotprime-k8s/yamls/dataserver/zotprime-dataserver-service.yaml
@@ -0,0 +1,20 @@
+apiVersion: v1
+kind: Service
+metadata:
+  creationTimestamp: null
+  labels:
+    move2kube.konveyor.io/service: zotprime-dataserver
+  name: zotprime-dataserver
+spec:
+  ports:
+    - name: port-8080
+      port: 8080
+      targetPort: 80
+    - name: port-8082
+      port: 8082
+      targetPort: 8082
+  selector:
+    move2kube.konveyor.io/service: zotprime-dataserver
+  type: ClusterIP
+status:
+  loadBalancer: {}
diff --git a/zotprime-k8s/yamls/db/configmap.yaml b/zotprime-k8s/yamls/db/configmap.yaml
new file mode 100644
index 00000000..2ea61ce3
--- /dev/null
+++ b/zotprime-k8s/yamls/db/configmap.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: db-config
+  namespace: zotprime
+data:
+  mariadb-user: zotero
+  mariadb-databasename: zoterotest
\ No newline at end of file
diff --git a/zotprime-k8s/yamls/db/secret.yaml_example b/zotprime-k8s/yamls/db/secret.yaml_example
new file mode 100644
index 00000000..2ce72a70
--- /dev/null
+++ b/zotprime-k8s/yamls/db/secret.yaml_example
@@ -0,0 +1,8 @@
+apiVersion: v1
+data:
+  secret.txt: TUFSSUFEQl9ST09UX1BBU1NXT1JEPXpvdGVybwpNQVJJQURCX1BBU1NXT1JEPXpvdGVyb3Rlc3QK
+kind: Secret
+metadata:
+  name: db-secret
+  namespace: zotprime
+type: Opaque
diff --git a/zotprime-k8s/yamls/db/secret/secret.txt_example b/zotprime-k8s/yamls/db/secret/secret.txt_example
new file mode 100644
index 00000000..a6773e99
--- /dev/null
+++ b/zotprime-k8s/yamls/db/secret/secret.txt_example
@@ -0,0 +1,2 @@
+MARIADB_ROOT_PASSWORD=zotero
+MARIADB_PASSWORD=zoterotest
diff --git a/zotprime-k8s/yamls/db/zotprime-db-service.yaml b/zotprime-k8s/yamls/db/zotprime-db-service.yaml
new file mode 100644
index 00000000..c5a8afc7
--- /dev/null
+++ b/zotprime-k8s/yamls/db/zotprime-db-service.yaml
@@ -0,0 +1,18 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: mysql
+  namespace: zotprime
+  annotations:
+    service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
+  labels:
+    apps: mysql
+spec:
+  ports:
+  - port: 3306
+    targetPort: 3306
+    name: mariadb
+  selector:
+    apps: zotprime-db
+#  clusterIP: None
+#  publishNotReadyAddresses: True
diff --git a/zotprime-k8s/yamls/db/zotprime-db.yaml b/zotprime-k8s/yamls/db/zotprime-db.yaml
new file mode 100644
index 00000000..5f142235
--- /dev/null
+++ b/zotprime-k8s/yamls/db/zotprime-db.yaml
@@ -0,0 +1,65 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: zotprime-db
+  namespace: zotprime
+  labels:
+    apps: zotprime-db
+spec:
+  serviceName: "db-service"
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-db
+  template:
+    metadata:
+      labels:
+        apps: zotprime-db
+    spec:
+      containers:
+      - name: mariadb
+        image: localhost:32000/zotprime-db:dev
+        ports:
+        - containerPort: 3306
+          name: mariadb-port
+        resources:
+          limits:
+            memory: 512Mi
+#            cpu: "1"
+            cpu: "0.5"
+        command:
+          - /bin/bash
+          - -c
+        args: 
+          -  set -o allexport && source tmp/_key/secret.txt && set +o allexport && /usr/local/bin/docker-entrypoint.sh mysqld         
+        env:
+        - name: MARIADB_USER
+          valueFrom:
+            configMapKeyRef:
+              name: db-config
+              key: mariadb-user
+        - name: MARIADB_DATABASE
+          valueFrom:
+            configMapKeyRef:
+              name: db-config
+              key: mariadb-databasename
+        volumeMounts:
+        - name: datadir
+          mountPath: /var/lib/mysql/
+        - name: db-secret
+          mountPath: "/tmp/_key"
+        securityContext:
+          runAsUser: 999
+          runAsGroup: 999
+      volumes:
+        - name: db-secret
+          secret:
+            secretName: db-secret
+  volumeClaimTemplates:
+  - metadata:
+      name: datadir
+    spec:
+      accessModes: [ "ReadWriteOnce" ]
+      resources:
+        requests:
+          storage: 300M
\ No newline at end of file
diff --git a/zotprime-k8s/yamls/elasticsearch/zotprime-elasticsearch-service.yaml b/zotprime-k8s/yamls/elasticsearch/zotprime-elasticsearch-service.yaml
new file mode 100644
index 00000000..244d5d48
--- /dev/null
+++ b/zotprime-k8s/yamls/elasticsearch/zotprime-elasticsearch-service.yaml
@@ -0,0 +1,15 @@
+
+apiVersion: v1
+kind: Service
+metadata:
+  name: elasticsearch
+  namespace: zotprime
+  labels:
+    apps: elasticsearch
+spec:
+  ports:
+  - port: 9300
+    name: transport
+  clusterIP: None
+  selector:
+    apps: zotprime-elasticsearch
\ No newline at end of file
diff --git a/zotprime-k8s/yamls/elasticsearch/zotprime-elasticsearch.yaml b/zotprime-k8s/yamls/elasticsearch/zotprime-elasticsearch.yaml
new file mode 100644
index 00000000..7e65ba37
--- /dev/null
+++ b/zotprime-k8s/yamls/elasticsearch/zotprime-elasticsearch.yaml
@@ -0,0 +1,97 @@
+
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: zotprime-elasticsearch
+  namespace: zotprime
+  labels:
+#    component: elasticsearch
+    apps: zotprime-elasticsearch
+spec:
+  selector:
+    matchLabels:
+      apps: zotprime-elasticsearch
+  serviceName: "elasticsearch-masterservice"
+  replicas: 1
+  template:
+    metadata:
+      labels:
+#        component: elasticsearch
+        apps: zotprime-elasticsearch
+    spec:
+      initContainers:
+      - name: init-sysctl
+        image: busybox:1.27.2
+        command:
+        - sysctl
+        - -w
+        - vm.max_map_count=262144
+        securityContext:
+          privileged: true
+      containers:
+      - name: elasticsearch
+        image: "localhost:32000/elasticsearch:8.7.0"
+        env:
+        - name: CLUSTER_NAME
+          value: zotero
+        - name: xpack.security.enabled
+          value: "true"
+        - name: cluster.routing.allocation.disk.threshold_enabled
+          value: "false"
+        - name: discovery.type
+          value: single-node
+        - name: NAMESPACE
+          valueFrom:
+            fieldRef:
+              fieldPath: metadata.namespace
+#        - name: NODE_NAME
+#          valueFrom:
+#            fieldRef:
+#              fieldPath: metadata.name
+#        - name: NUMBER_OF_MASTERS
+#          value: "2"
+#        - name: NODE_MASTER
+#          value: "true"
+#        - name: NODE_INGEST
+#          value: "false"
+#        - name: NODE_DATA
+#          value: "false"
+#        - name: HTTP_ENABLE
+#          value: "false"
+#        - name: ES_JAVA_OPTS
+#          value: -Xms256m -Xmx256m
+        - name: PROCESSORS
+          valueFrom:
+            resourceFieldRef:
+              resource: limits.cpu
+        resources:
+          limits:
+            cpu: "0.5"
+#            cpu: "1"
+#            memory: 2Gi
+            memory: 512M
+          requests:
+            cpu: 100m
+#            cpu: "0.25"
+#            memory: 1500Mi
+            memory: 256M
+        ports:
+        - containerPort: 9300
+          name: transport
+#        livenessProbe:
+#          tcpSocket:
+#            port: transport
+#          initialDelaySeconds: 120
+#          periodSeconds: 10
+        volumeMounts:
+        - name: storage
+          mountPath: /data
+  volumeClaimTemplates:
+  - metadata:
+      name: storage
+    spec:
+#      storageClassName: standard
+      accessModes: [ ReadWriteOnce ]
+      resources:
+        requests:
+          storage: 2Gi
diff --git a/zotprime-k8s/yamls/k8s/ingress/zotprime-k8s-ingress.yaml b/zotprime-k8s/yamls/k8s/ingress/zotprime-k8s-ingress.yaml
new file mode 100644
index 00000000..f87a7c7b
--- /dev/null
+++ b/zotprime-k8s/yamls/k8s/ingress/zotprime-k8s-ingress.yaml
@@ -0,0 +1,68 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+  creationTimestamp: null
+  labels:
+    zotprime/service: zotprime-k8s
+  name: zotprime-k8s
+  namespace: zotprime
+  annotations:
+#    nginx.ingress.kubernetes.io/rewrite-target: /
+    nginx.ingress.kubernetes.io/rewrite-target: /$1
+    nginx.ingress.kubernetes.io/use-forwarded-headers: "true"
+spec:
+  rules:
+    - host: zotprime.localdomain
+      http:
+        paths:
+          - backend:
+              service:
+                name: zotprime-streamserver
+                port:
+                  name: port-8081
+            path: /streamserver
+            pathType: Prefix
+#          - backend:
+#              service:
+#                name: zotprime-tinymceclean
+#                port:
+#                  name: port-16342
+#            path: /zotprime-tinymceclean
+#            pathType: Prefix
+          - backend:
+              service:
+                name: zotprime-minio
+                port:
+                  name: port-9001
+            path: /minio
+            pathType: Prefix
+          - backend:
+              service:
+                name: zotprime-dataserver
+                port:
+                  name: port-8080
+            path: /dataserver
+            pathType: Prefix
+#status:
+#  loadBalancer: {}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: zotprime-ingress
+  namespace: ingress
+spec:
+  selector:
+    zotprime/service: zotprime-k8s
+#    name: zotprime-k8s
+  type: LoadBalancer
+  loadBalancerIP: 10.77.77.228
+  ports:
+    - name: http
+      protocol: TCP
+      port: 80
+      targetPort: 80
+    - name: https
+      protocol: TCP
+      port: 443
+      targetPort: 443
\ No newline at end of file
diff --git a/zotprime-k8s/yamls/k8s/namespace/zotprime-namespace.yaml b/zotprime-k8s/yamls/k8s/namespace/zotprime-namespace.yaml
new file mode 100644
index 00000000..764e2f42
--- /dev/null
+++ b/zotprime-k8s/yamls/k8s/namespace/zotprime-namespace.yaml
@@ -0,0 +1,7 @@
+# Deploys a new Namespace for the ZotPrime
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: zotprime
+  labels:
+    name: zotprime
\ No newline at end of file
diff --git a/zotprime-k8s/yamls/localstack/localstack/templates/deployment.yaml b/zotprime-k8s/yamls/localstack/localstack/templates/deployment.yaml
new file mode 100644
index 00000000..a923779f
--- /dev/null
+++ b/zotprime-k8s/yamls/localstack/localstack/templates/deployment.yaml
@@ -0,0 +1,234 @@
+---
+# Source: localstack/templates/deployment.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: localstack
+  namespace: "default"
+  labels:
+    helm.sh/chart: localstack-0.6.0
+    app.kubernetes.io/name: localstack
+    app.kubernetes.io/instance: localstack
+    app.kubernetes.io/version: "latest"
+    app.kubernetes.io/managed-by: Helm
+  annotations:
+    
+spec:
+  replicas: 1
+  strategy:
+    type: RollingUpdate
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: localstack
+      app.kubernetes.io/instance: localstack
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/name: localstack
+        app.kubernetes.io/instance: localstack
+    spec:
+      serviceAccountName: localstack
+      securityContext:
+        {}
+      containers:
+        - name: localstack
+          securityContext:
+            {}
+          image: "localstack/localstack:latest"
+          imagePullPolicy: IfNotPresent
+          ports:
+            - name: edge
+              containerPort: 4566
+              protocol: TCP
+            - name: "ext-svc-4510"
+              containerPort: 4510
+              protocol: TCP
+            - name: "ext-svc-4511"
+              containerPort: 4511
+              protocol: TCP
+            - name: "ext-svc-4512"
+              containerPort: 4512
+              protocol: TCP
+            - name: "ext-svc-4513"
+              containerPort: 4513
+              protocol: TCP
+            - name: "ext-svc-4514"
+              containerPort: 4514
+              protocol: TCP
+            - name: "ext-svc-4515"
+              containerPort: 4515
+              protocol: TCP
+            - name: "ext-svc-4516"
+              containerPort: 4516
+              protocol: TCP
+            - name: "ext-svc-4517"
+              containerPort: 4517
+              protocol: TCP
+            - name: "ext-svc-4518"
+              containerPort: 4518
+              protocol: TCP
+            - name: "ext-svc-4519"
+              containerPort: 4519
+              protocol: TCP
+            - name: "ext-svc-4520"
+              containerPort: 4520
+              protocol: TCP
+            - name: "ext-svc-4521"
+              containerPort: 4521
+              protocol: TCP
+            - name: "ext-svc-4522"
+              containerPort: 4522
+              protocol: TCP
+            - name: "ext-svc-4523"
+              containerPort: 4523
+              protocol: TCP
+            - name: "ext-svc-4524"
+              containerPort: 4524
+              protocol: TCP
+            - name: "ext-svc-4525"
+              containerPort: 4525
+              protocol: TCP
+            - name: "ext-svc-4526"
+              containerPort: 4526
+              protocol: TCP
+            - name: "ext-svc-4527"
+              containerPort: 4527
+              protocol: TCP
+            - name: "ext-svc-4528"
+              containerPort: 4528
+              protocol: TCP
+            - name: "ext-svc-4529"
+              containerPort: 4529
+              protocol: TCP
+            - name: "ext-svc-4530"
+              containerPort: 4530
+              protocol: TCP
+            - name: "ext-svc-4531"
+              containerPort: 4531
+              protocol: TCP
+            - name: "ext-svc-4532"
+              containerPort: 4532
+              protocol: TCP
+            - name: "ext-svc-4533"
+              containerPort: 4533
+              protocol: TCP
+            - name: "ext-svc-4534"
+              containerPort: 4534
+              protocol: TCP
+            - name: "ext-svc-4535"
+              containerPort: 4535
+              protocol: TCP
+            - name: "ext-svc-4536"
+              containerPort: 4536
+              protocol: TCP
+            - name: "ext-svc-4537"
+              containerPort: 4537
+              protocol: TCP
+            - name: "ext-svc-4538"
+              containerPort: 4538
+              protocol: TCP
+            - name: "ext-svc-4539"
+              containerPort: 4539
+              protocol: TCP
+            - name: "ext-svc-4540"
+              containerPort: 4540
+              protocol: TCP
+            - name: "ext-svc-4541"
+              containerPort: 4541
+              protocol: TCP
+            - name: "ext-svc-4542"
+              containerPort: 4542
+              protocol: TCP
+            - name: "ext-svc-4543"
+              containerPort: 4543
+              protocol: TCP
+            - name: "ext-svc-4544"
+              containerPort: 4544
+              protocol: TCP
+            - name: "ext-svc-4545"
+              containerPort: 4545
+              protocol: TCP
+            - name: "ext-svc-4546"
+              containerPort: 4546
+              protocol: TCP
+            - name: "ext-svc-4547"
+              containerPort: 4547
+              protocol: TCP
+            - name: "ext-svc-4548"
+              containerPort: 4548
+              protocol: TCP
+            - name: "ext-svc-4549"
+              containerPort: 4549
+              protocol: TCP
+            - name: "ext-svc-4550"
+              containerPort: 4550
+              protocol: TCP
+            - name: "ext-svc-4551"
+              containerPort: 4551
+              protocol: TCP
+            - name: "ext-svc-4552"
+              containerPort: 4552
+              protocol: TCP
+            - name: "ext-svc-4553"
+              containerPort: 4553
+              protocol: TCP
+            - name: "ext-svc-4554"
+              containerPort: 4554
+              protocol: TCP
+            - name: "ext-svc-4555"
+              containerPort: 4555
+              protocol: TCP
+            - name: "ext-svc-4556"
+              containerPort: 4556
+              protocol: TCP
+            - name: "ext-svc-4557"
+              containerPort: 4557
+              protocol: TCP
+            - name: "ext-svc-4558"
+              containerPort: 4558
+              protocol: TCP
+            - name: "ext-svc-4559"
+              containerPort: 4559
+              protocol: TCP
+          livenessProbe:
+            failureThreshold: 3
+            initialDelaySeconds: 0
+            periodSeconds: 10
+            successThreshold: 1
+            timeoutSeconds: 1
+            httpGet:
+              path: /_localstack/health
+              port: edge
+          readinessProbe:
+            failureThreshold: 3
+            initialDelaySeconds: 0
+            periodSeconds: 10
+            successThreshold: 1
+            timeoutSeconds: 1
+            httpGet:
+              path: /_localstack/health
+              port: edge
+          resources:
+            {}
+          env:
+            - name: DEBUG
+              value: "0"
+            - name: EXTERNAL_SERVICE_PORTS_START
+              value: "4510"
+            - name: EXTERNAL_SERVICE_PORTS_END
+              value: "4560"
+            - name: LOCALSTACK_K8S_SERVICE_NAME
+              value: localstack
+            - name: LOCALSTACK_K8S_NAMESPACE
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.namespace
+            - name: LAMBDA_RUNTIME_EXECUTOR
+              value: "docker"
+            - name: LAMBDA_K8S_IMAGE_PREFIX
+              value: "localstack/lambda-"
+            - name: LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT
+              value: "60"
+            - name: OVERRIDE_IN_DOCKER
+              value: "1"
+      volumes:
diff --git a/zotprime-k8s/yamls/localstack/localstack/templates/role.yaml b/zotprime-k8s/yamls/localstack/localstack/templates/role.yaml
new file mode 100644
index 00000000..99d9b48c
--- /dev/null
+++ b/zotprime-k8s/yamls/localstack/localstack/templates/role.yaml
@@ -0,0 +1,17 @@
+---
+# Source: localstack/templates/role.yaml
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  namespace: "default"
+  name: localstack
+  labels:
+    helm.sh/chart: localstack-0.6.0
+    app.kubernetes.io/name: localstack
+    app.kubernetes.io/instance: localstack
+    app.kubernetes.io/version: "latest"
+    app.kubernetes.io/managed-by: Helm
+rules:
+- apiGroups: [""] # "" indicates the core API group
+  resources: ["pods"]
+  verbs: ["*"]
diff --git a/zotprime-k8s/yamls/localstack/localstack/templates/rolebinding.yaml b/zotprime-k8s/yamls/localstack/localstack/templates/rolebinding.yaml
new file mode 100644
index 00000000..439ff58a
--- /dev/null
+++ b/zotprime-k8s/yamls/localstack/localstack/templates/rolebinding.yaml
@@ -0,0 +1,21 @@
+---
+# Source: localstack/templates/rolebinding.yaml
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: localstack
+  namespace: "default"
+  labels:
+    helm.sh/chart: localstack-0.6.0
+    app.kubernetes.io/name: localstack
+    app.kubernetes.io/instance: localstack
+    app.kubernetes.io/version: "latest"
+    app.kubernetes.io/managed-by: Helm
+subjects:
+# You can specify more than one "subject"
+- kind: ServiceAccount
+  name: localstack
+roleRef:
+  kind: Role
+  name: localstack
+  apiGroup: rbac.authorization.k8s.io
diff --git a/zotprime-k8s/yamls/localstack/localstack/templates/service.yaml b/zotprime-k8s/yamls/localstack/localstack/templates/service.yaml
new file mode 100644
index 00000000..431eb1da
--- /dev/null
+++ b/zotprime-k8s/yamls/localstack/localstack/templates/service.yaml
@@ -0,0 +1,176 @@
+---
+# Source: localstack/templates/service.yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: localstack
+  namespace: "default"
+  labels:
+    helm.sh/chart: localstack-0.6.0
+    app.kubernetes.io/name: localstack
+    app.kubernetes.io/instance: localstack
+    app.kubernetes.io/version: "latest"
+    app.kubernetes.io/managed-by: Helm
+  annotations:
+    
+spec:
+  type: NodePort
+  externalTrafficPolicy: ""
+  ports:
+    - name: edge
+      port: 4566
+      targetPort: 4566
+      nodePort: 31566
+    - name: "external-service-port-4510"
+      port: 4510
+      targetPort: "ext-svc-4510"
+    - name: "external-service-port-4511"
+      port: 4511
+      targetPort: "ext-svc-4511"
+    - name: "external-service-port-4512"
+      port: 4512
+      targetPort: "ext-svc-4512"
+    - name: "external-service-port-4513"
+      port: 4513
+      targetPort: "ext-svc-4513"
+    - name: "external-service-port-4514"
+      port: 4514
+      targetPort: "ext-svc-4514"
+    - name: "external-service-port-4515"
+      port: 4515
+      targetPort: "ext-svc-4515"
+    - name: "external-service-port-4516"
+      port: 4516
+      targetPort: "ext-svc-4516"
+    - name: "external-service-port-4517"
+      port: 4517
+      targetPort: "ext-svc-4517"
+    - name: "external-service-port-4518"
+      port: 4518
+      targetPort: "ext-svc-4518"
+    - name: "external-service-port-4519"
+      port: 4519
+      targetPort: "ext-svc-4519"
+    - name: "external-service-port-4520"
+      port: 4520
+      targetPort: "ext-svc-4520"
+    - name: "external-service-port-4521"
+      port: 4521
+      targetPort: "ext-svc-4521"
+    - name: "external-service-port-4522"
+      port: 4522
+      targetPort: "ext-svc-4522"
+    - name: "external-service-port-4523"
+      port: 4523
+      targetPort: "ext-svc-4523"
+    - name: "external-service-port-4524"
+      port: 4524
+      targetPort: "ext-svc-4524"
+    - name: "external-service-port-4525"
+      port: 4525
+      targetPort: "ext-svc-4525"
+    - name: "external-service-port-4526"
+      port: 4526
+      targetPort: "ext-svc-4526"
+    - name: "external-service-port-4527"
+      port: 4527
+      targetPort: "ext-svc-4527"
+    - name: "external-service-port-4528"
+      port: 4528
+      targetPort: "ext-svc-4528"
+    - name: "external-service-port-4529"
+      port: 4529
+      targetPort: "ext-svc-4529"
+    - name: "external-service-port-4530"
+      port: 4530
+      targetPort: "ext-svc-4530"
+    - name: "external-service-port-4531"
+      port: 4531
+      targetPort: "ext-svc-4531"
+    - name: "external-service-port-4532"
+      port: 4532
+      targetPort: "ext-svc-4532"
+    - name: "external-service-port-4533"
+      port: 4533
+      targetPort: "ext-svc-4533"
+    - name: "external-service-port-4534"
+      port: 4534
+      targetPort: "ext-svc-4534"
+    - name: "external-service-port-4535"
+      port: 4535
+      targetPort: "ext-svc-4535"
+    - name: "external-service-port-4536"
+      port: 4536
+      targetPort: "ext-svc-4536"
+    - name: "external-service-port-4537"
+      port: 4537
+      targetPort: "ext-svc-4537"
+    - name: "external-service-port-4538"
+      port: 4538
+      targetPort: "ext-svc-4538"
+    - name: "external-service-port-4539"
+      port: 4539
+      targetPort: "ext-svc-4539"
+    - name: "external-service-port-4540"
+      port: 4540
+      targetPort: "ext-svc-4540"
+    - name: "external-service-port-4541"
+      port: 4541
+      targetPort: "ext-svc-4541"
+    - name: "external-service-port-4542"
+      port: 4542
+      targetPort: "ext-svc-4542"
+    - name: "external-service-port-4543"
+      port: 4543
+      targetPort: "ext-svc-4543"
+    - name: "external-service-port-4544"
+      port: 4544
+      targetPort: "ext-svc-4544"
+    - name: "external-service-port-4545"
+      port: 4545
+      targetPort: "ext-svc-4545"
+    - name: "external-service-port-4546"
+      port: 4546
+      targetPort: "ext-svc-4546"
+    - name: "external-service-port-4547"
+      port: 4547
+      targetPort: "ext-svc-4547"
+    - name: "external-service-port-4548"
+      port: 4548
+      targetPort: "ext-svc-4548"
+    - name: "external-service-port-4549"
+      port: 4549
+      targetPort: "ext-svc-4549"
+    - name: "external-service-port-4550"
+      port: 4550
+      targetPort: "ext-svc-4550"
+    - name: "external-service-port-4551"
+      port: 4551
+      targetPort: "ext-svc-4551"
+    - name: "external-service-port-4552"
+      port: 4552
+      targetPort: "ext-svc-4552"
+    - name: "external-service-port-4553"
+      port: 4553
+      targetPort: "ext-svc-4553"
+    - name: "external-service-port-4554"
+      port: 4554
+      targetPort: "ext-svc-4554"
+    - name: "external-service-port-4555"
+      port: 4555
+      targetPort: "ext-svc-4555"
+    - name: "external-service-port-4556"
+      port: 4556
+      targetPort: "ext-svc-4556"
+    - name: "external-service-port-4557"
+      port: 4557
+      targetPort: "ext-svc-4557"
+    - name: "external-service-port-4558"
+      port: 4558
+      targetPort: "ext-svc-4558"
+    - name: "external-service-port-4559"
+      port: 4559
+      targetPort: "ext-svc-4559"
+  selector:
+    app.kubernetes.io/name: localstack
+    app.kubernetes.io/instance: localstack
diff --git a/zotprime-k8s/yamls/localstack/localstack/templates/serviceaccount.yaml b/zotprime-k8s/yamls/localstack/localstack/templates/serviceaccount.yaml
new file mode 100644
index 00000000..5b1f85a8
--- /dev/null
+++ b/zotprime-k8s/yamls/localstack/localstack/templates/serviceaccount.yaml
@@ -0,0 +1,14 @@
+---
+# Source: localstack/templates/serviceaccount.yaml
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: localstack
+  namespace: "default"
+  labels:
+    helm.sh/chart: localstack-0.6.0
+    app.kubernetes.io/name: localstack
+    app.kubernetes.io/instance: localstack
+    app.kubernetes.io/version: "latest"
+    app.kubernetes.io/managed-by: Helm
+  annotations:
diff --git a/zotprime-k8s/yamls/localstack/localstack/templates/tests/test-connection.yaml b/zotprime-k8s/yamls/localstack/localstack/templates/tests/test-connection.yaml
new file mode 100644
index 00000000..5df51c23
--- /dev/null
+++ b/zotprime-k8s/yamls/localstack/localstack/templates/tests/test-connection.yaml
@@ -0,0 +1,21 @@
+---
+# Source: localstack/templates/tests/test-connection.yaml
+apiVersion: v1
+kind: Pod
+metadata:
+  name: "localstack-test-connection"
+  labels:
+    app.kubernetes.io/name: localstack
+    helm.sh/chart: localstack-0.6.0
+    app.kubernetes.io/instance: localstack
+    app.kubernetes.io/managed-by: Helm
+  annotations:
+    "helm.sh/hook": test
+    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
+spec:
+  containers:
+    - name: wget-edge
+      image: busybox
+      command: ['wget']
+      args: ['-q', '--server-response', '--output-document', '-', 'localstack:4566/_localstack/health']
+  restartPolicy: Never
diff --git a/zotprime-k8s/yamls/localstack/localstack/templates/tests/test-s3.yaml b/zotprime-k8s/yamls/localstack/localstack/templates/tests/test-s3.yaml
new file mode 100644
index 00000000..96719e1a
--- /dev/null
+++ b/zotprime-k8s/yamls/localstack/localstack/templates/tests/test-s3.yaml
@@ -0,0 +1,27 @@
+---
+# Source: localstack/templates/tests/test-s3.yaml
+apiVersion: v1
+kind: Pod
+metadata:
+  name: "localstack-test-s3"
+  labels:
+    app.kubernetes.io/name: localstack
+    helm.sh/chart: localstack-0.6.0
+    app.kubernetes.io/instance: localstack
+    app.kubernetes.io/managed-by: Helm
+  annotations:
+    "helm.sh/hook": test
+    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
+spec:
+  containers:
+    - name: awscli-s3
+      image: amazon/aws-cli
+      args: ['--debug', '--endpoint-url', 'http://localstack:4566', 's3', 'ls']
+      env:
+      - name: AWS_ACCESS_KEY_ID
+        value: test
+      - name: AWS_SECRET_ACCESS_KEY
+        value: test
+      - name: AWS_DEFAULT_REGION
+        value: us-east-1
+  restartPolicy: Never
diff --git a/zotprime-k8s/yamls/localstack/zotprime-localstack-service.yaml b/zotprime-k8s/yamls/localstack/zotprime-localstack-service.yaml
new file mode 100644
index 00000000..921d80e9
--- /dev/null
+++ b/zotprime-k8s/yamls/localstack/zotprime-localstack-service.yaml
@@ -0,0 +1,27 @@
+---
+# Source: localstack/templates/service.yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: localstack
+  namespace: "zotprime"
+  labels:
+    apps: localstack
+#  annotations:
+    
+spec:
+#  type: NodePort
+#  externalTrafficPolicy: ""
+  ports:
+    - name: "sns-4575"
+      port: 4575
+      targetPort: "sns-4575"
+#      nodePort: 31566
+    - name: "sqs-4576"
+      port: 4576
+      targetPort: "sqs-4576"
+    - name: "apigateway-4567"
+      port: 4567
+      targetPort: "apigateway-4567"
+  selector:
+    apps: zotprime-localstack
diff --git a/zotprime-k8s/yamls/localstack/zotprime-localstack.yaml b/zotprime-k8s/yamls/localstack/zotprime-localstack.yaml
new file mode 100644
index 00000000..be866171
--- /dev/null
+++ b/zotprime-k8s/yamls/localstack/zotprime-localstack.yaml
@@ -0,0 +1,242 @@
+---
+# Source: localstack/templates/deployment.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: zotprime-localstack
+  namespace: "zotprime"
+  labels:
+    apps: zotprime-localstack
+#  annotations:
+    
+spec:
+  replicas: 1
+  strategy:
+    type: RollingUpdate
+  selector:
+    matchLabels:
+      apps: zotprime-localstack
+      
+  template:
+    metadata:
+      labels:
+        apps: zotprime-localstack
+    spec:
+#      serviceAccountName: localstack
+      securityContext:
+        {}
+      containers:
+        - name: localstack
+          securityContext:
+            {}
+          image: "localhost:32000/localstack:latest"
+          imagePullPolicy: IfNotPresent
+          ports:
+            - name: edge
+              containerPort: 4566
+              protocol: TCP
+            - name: "sns-4575"
+              containerPort: 4575
+              protocol: TCP
+            - name: "sqs-4576"
+              containerPort: 4576
+              protocol: TCP
+            - name: "apigateway-4567"
+              containerPort: 4567
+              protocol: TCP
+            - name: "ext-svc-4510"
+              containerPort: 4510
+              protocol: TCP
+            - name: "ext-svc-4511"
+              containerPort: 4511
+              protocol: TCP
+            - name: "ext-svc-4512"
+              containerPort: 4512
+              protocol: TCP
+            - name: "ext-svc-4513"
+              containerPort: 4513
+              protocol: TCP
+            - name: "ext-svc-4514"
+              containerPort: 4514
+              protocol: TCP
+            - name: "ext-svc-4515"
+              containerPort: 4515
+              protocol: TCP
+            - name: "ext-svc-4516"
+              containerPort: 4516
+              protocol: TCP
+            - name: "ext-svc-4517"
+              containerPort: 4517
+              protocol: TCP
+            - name: "ext-svc-4518"
+              containerPort: 4518
+              protocol: TCP
+            - name: "ext-svc-4519"
+              containerPort: 4519
+              protocol: TCP
+            - name: "ext-svc-4520"
+              containerPort: 4520
+              protocol: TCP
+            - name: "ext-svc-4521"
+              containerPort: 4521
+              protocol: TCP
+            - name: "ext-svc-4522"
+              containerPort: 4522
+              protocol: TCP
+            - name: "ext-svc-4523"
+              containerPort: 4523
+              protocol: TCP
+            - name: "ext-svc-4524"
+              containerPort: 4524
+              protocol: TCP
+            - name: "ext-svc-4525"
+              containerPort: 4525
+              protocol: TCP
+            - name: "ext-svc-4526"
+              containerPort: 4526
+              protocol: TCP
+            - name: "ext-svc-4527"
+              containerPort: 4527
+              protocol: TCP
+            - name: "ext-svc-4528"
+              containerPort: 4528
+              protocol: TCP
+            - name: "ext-svc-4529"
+              containerPort: 4529
+              protocol: TCP
+            - name: "ext-svc-4530"
+              containerPort: 4530
+              protocol: TCP
+            - name: "ext-svc-4531"
+              containerPort: 4531
+              protocol: TCP
+            - name: "ext-svc-4532"
+              containerPort: 4532
+              protocol: TCP
+            - name: "ext-svc-4533"
+              containerPort: 4533
+              protocol: TCP
+            - name: "ext-svc-4534"
+              containerPort: 4534
+              protocol: TCP
+            - name: "ext-svc-4535"
+              containerPort: 4535
+              protocol: TCP
+            - name: "ext-svc-4536"
+              containerPort: 4536
+              protocol: TCP
+            - name: "ext-svc-4537"
+              containerPort: 4537
+              protocol: TCP
+            - name: "ext-svc-4538"
+              containerPort: 4538
+              protocol: TCP
+            - name: "ext-svc-4539"
+              containerPort: 4539
+              protocol: TCP
+            - name: "ext-svc-4540"
+              containerPort: 4540
+              protocol: TCP
+            - name: "ext-svc-4541"
+              containerPort: 4541
+              protocol: TCP
+            - name: "ext-svc-4542"
+              containerPort: 4542
+              protocol: TCP
+            - name: "ext-svc-4543"
+              containerPort: 4543
+              protocol: TCP
+            - name: "ext-svc-4544"
+              containerPort: 4544
+              protocol: TCP
+            - name: "ext-svc-4545"
+              containerPort: 4545
+              protocol: TCP
+            - name: "ext-svc-4546"
+              containerPort: 4546
+              protocol: TCP
+            - name: "ext-svc-4547"
+              containerPort: 4547
+              protocol: TCP
+            - name: "ext-svc-4548"
+              containerPort: 4548
+              protocol: TCP
+            - name: "ext-svc-4549"
+              containerPort: 4549
+              protocol: TCP
+            - name: "ext-svc-4550"
+              containerPort: 4550
+              protocol: TCP
+            - name: "ext-svc-4551"
+              containerPort: 4551
+              protocol: TCP
+            - name: "ext-svc-4552"
+              containerPort: 4552
+              protocol: TCP
+            - name: "ext-svc-4553"
+              containerPort: 4553
+              protocol: TCP
+            - name: "ext-svc-4554"
+              containerPort: 4554
+              protocol: TCP
+            - name: "ext-svc-4555"
+              containerPort: 4555
+              protocol: TCP
+            - name: "ext-svc-4556"
+              containerPort: 4556
+              protocol: TCP
+            - name: "ext-svc-4557"
+              containerPort: 4557
+              protocol: TCP
+            - name: "ext-svc-4558"
+              containerPort: 4558
+              protocol: TCP
+            - name: "ext-svc-4559"
+              containerPort: 4559
+              protocol: TCP
+#          livenessProbe:
+#            failureThreshold: 3
+#            initialDelaySeconds: 0
+#            periodSeconds: 10
+#            successThreshold: 1
+#            timeoutSeconds: 1
+#            httpGet:
+#              path: /_localstack/health
+#              port: edge
+#          readinessProbe:
+#            failureThreshold: 3
+#            initialDelaySeconds: 0
+#            periodSeconds: 10
+#            successThreshold: 1
+#            timeoutSeconds: 1
+#            httpGet:
+#              path: /_localstack/health
+#              port: edge
+          resources:
+            limits:
+              memory: 256Mi
+              cpu: "0.1"
+          env:
+            - name: SERVICES
+              value: "sns,sqs,apigateway"
+            - name: DEBUG
+              value: "0"
+#            - name: EXTERNAL_SERVICE_PORTS_START
+#              value: "4510"
+#            - name: EXTERNAL_SERVICE_PORTS_END
+#              value: "4560"
+            - name: LOCALSTACK_K8S_SERVICE_NAME
+              value: localstack
+            - name: LOCALSTACK_K8S_NAMESPACE
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.namespace
+#            - name: LAMBDA_RUNTIME_EXECUTOR
+#              value: "docker"
+#            - name: LAMBDA_K8S_IMAGE_PREFIX
+#              value: "localstack/lambda-"
+#            - name: LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT
+#              value: "60"
+#            - name: OVERRIDE_IN_DOCKER
+#              value: "1"
+#      volumes:
diff --git a/zotprime-k8s/yamls/memcached/zotprime-memcached.yaml b/zotprime-k8s/yamls/memcached/zotprime-memcached.yaml
new file mode 100644
index 00000000..85882182
--- /dev/null
+++ b/zotprime-k8s/yamls/memcached/zotprime-memcached.yaml
@@ -0,0 +1,39 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: zotprime-memcached
+  namespace: zotprime
+  labels:
+    apps: zotprime-memcached
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-memcached
+  template:
+    metadata:
+      name: memcached
+      labels:
+        apps: zotprime-memcached
+    spec:
+      containers:
+      - name: memcached
+        image: memcached:1.5
+        resources:
+          limits:
+            cpu: 200m
+            memory: 2000Mi
+#          requests:
+#            cpu: 100m
+#            memory: 1000Mi
+        ports:
+          - containerPort: 11211
+            protocol: TCP
+        args:
+          - -m 2047
+          - -I 50M
+#      - name: memcached-exporter
+#        image: prom/memcached-exporter
+#        ports:
+#          - containerPort: 9150
+#            protocol: TCP
diff --git a/zotprime-k8s/yamls/minio/configmap.yaml b/zotprime-k8s/yamls/minio/configmap.yaml
new file mode 100644
index 00000000..fb07fb61
--- /dev/null
+++ b/zotprime-k8s/yamls/minio/configmap.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: minio-config
+  namespace: zotprime
+data:
+  minio-user: zotero
\ No newline at end of file
diff --git a/zotprime-k8s/yamls/minio/secret.yaml_example b/zotprime-k8s/yamls/minio/secret.yaml_example
new file mode 100644
index 00000000..bfa70dbd
--- /dev/null
+++ b/zotprime-k8s/yamls/minio/secret.yaml_example
@@ -0,0 +1,8 @@
+apiVersion: v1
+data:
+  secret.txt: TUlOSU9fUk9PVF9QQVNTV09SRD16b3Rlcm9kb2NrZXIK
+kind: Secret
+metadata:
+  name: minio-secret
+  namespace: zotprime
+type: Opaque
diff --git a/zotprime-k8s/yamls/minio/secret/secret.txt_example b/zotprime-k8s/yamls/minio/secret/secret.txt_example
new file mode 100644
index 00000000..96006eed
--- /dev/null
+++ b/zotprime-k8s/yamls/minio/secret/secret.txt_example
@@ -0,0 +1 @@
+MINIO_ROOT_PASSWORD=zoterodocker
diff --git a/zotprime-k8s/yamls/minio/zotprime-minio-service.yaml b/zotprime-k8s/yamls/minio/zotprime-minio-service.yaml
new file mode 100644
index 00000000..93402c6b
--- /dev/null
+++ b/zotprime-k8s/yamls/minio/zotprime-minio-service.yaml
@@ -0,0 +1,20 @@
+apiVersion: v1
+kind: Service
+metadata:
+  creationTimestamp: null
+  labels:
+#    move2kube.konveyor.io/service: zotprime-minio
+    app: zotprime-minio
+  name: zotprime-minio
+  namespace: zotprime
+spec:
+  ports:
+    - name: port-9001
+      port: 9001
+      targetPort: 9001
+  selector:
+#    move2kube.konveyor.io/service: zotprime-minio
+    app: zotprime-minio
+  type: ClusterIP
+status:
+  loadBalancer: {}
diff --git a/zotprime-k8s/yamls/minio/zotprime-minio.yaml b/zotprime-k8s/yamls/minio/zotprime-minio.yaml
new file mode 100644
index 00000000..44ea261f
--- /dev/null
+++ b/zotprime-k8s/yamls/minio/zotprime-minio.yaml
@@ -0,0 +1,76 @@
+# Deploys a new MinIO Pod into the metadata.namespace Kubernetes namespace
+#
+# The `spec.containers[0].args` contains the command run on the pod
+# The `/data` directory corresponds to the `spec.containers[0].volumeMounts[0].mountPath`
+# That mount path corresponds to a Kubernetes HostPath which binds `/data` to a local drive or volume on the worker node where the pod runs
+# 
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    app: zotprime-minio
+  name: zotprime-minio
+  namespace: zotprime # Change this value to match the namespace metadata.name
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-minio
+  strategy: {}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-minio
+      name: zotprime-minio
+    spec:
+      containers:
+        - name: zotprime-minio
+          image: quay.io/minio/minio:latest
+#          imagePullPolicy: Always
+          resources:
+            limits:
+              memory: 512Mi
+              cpu: "1"
+          command:
+            - /bin/bash
+            - -c
+          args: 
+            -  set -o allexport && source tmp/_key/secret.txt && set +o allexport && minio server /data --console-address :9001 
+#| tee /data/test.log
+#    lifecycle:
+#      preStop:
+#        exec:
+#          command:
+#            - /bin/sh
+#            - -c
+#          args: ls -lha /tmp/_key/
+          env:
+            - name: MINIO_ROOT_USER
+              valueFrom:
+                 configMapKeyRef:
+                   name: minio-config
+                   key: minio-user
+          volumeMounts:
+            - name: localvolumeminio # Corresponds to the `spec.volumes` Persistent Volume
+              mountPath: /data
+            - name: minio-secret
+              mountPath: "/tmp/_key"
+#             subPath: secret.txt
+              readOnly: true
+      nodeSelector:
+        kubernetes.io/hostname: localhost.localdomain # Specify a node label associated to the Worker Node on which you want to deploy the pod.
+#  terminationGracePeriodSeconds: 30
+      restartPolicy: Always
+      securityContext: {}
+      volumes:
+        - name: localvolumeminio
+          hostPath: # MinIO generally recommends using locally-attached volumes
+            path: /mnt/disk1/data # Specify a path to a local drive or volume on the Kubernetes worker node
+            type: DirectoryOrCreate # The path to the last directory must exist
+        - name: minio-secret
+          secret:
+            secretName: minio-secret
+#      items:
+#        - key: _key
+#          path: _key
+status: {}
diff --git a/zotprime-k8s/yamls/phpmyadmin/zotprime-phpmyadmin.yaml b/zotprime-k8s/yamls/phpmyadmin/zotprime-phpmyadmin.yaml
new file mode 100644
index 00000000..335230ff
--- /dev/null
+++ b/zotprime-k8s/yamls/phpmyadmin/zotprime-phpmyadmin.yaml
@@ -0,0 +1,41 @@
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: zotprime-phpmyadmin
+  namespace: zotprime
+  labels:
+    apps: zotprime-phpmyadmin
+spec:
+  selector:
+    matchLabels:
+      apps: zotprime-phpmyadmin
+  template:
+    metadata:
+      labels:
+        apps: zotprime-phpmyadmin
+    spec:
+      containers:
+        - name: phpmyadmin
+          image: localhost:32000/zotprime-phpmyadmin:dev
+          ports:
+            - containerPort: 80
+          resources:
+            limits:
+              memory: 256Mi
+              cpu: "0.1"
+          env:
+            - name: PMA_HOST
+              value: mysql
+            - name: PMA_PORT
+              value: "3306"
+#            - name: PMA_ARBITRARY
+#              value: "1"
+#        - name: PMA_ABSOLUTE_URI
+#          value: [uri]
+
+#        - name: MYSQL_ROOT_PASSWORD
+#          valueFrom:
+#            secretKeyRef:
+#              name: mysql-secret
+#              key: root_password
\ No newline at end of file
diff --git a/zotprime-k8s/yamls/redis/configmap.yaml b/zotprime-k8s/yamls/redis/configmap.yaml
new file mode 100644
index 00000000..44bd7c59
--- /dev/null
+++ b/zotprime-k8s/yamls/redis/configmap.yaml
@@ -0,0 +1,44 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: redis-config
+  namespace: zotprime
+data:
+  redis.conf:  |+
+    cluster-enabled no
+    appendonly yes
+    protected-mode no
+    dir /data
+    port 6379
+
+# cluster-node-timeout 15000
+# cluster-config-file /data/nodes.conf
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: redis-probes
+  namespace: zotprime
+data:
+  readiness.sh: |-
+    #!/bin/sh
+    pingResponse="$(redis-cli -h localhost ping)"
+    if [ "$?" -eq "124" ]; then
+      echo "PING timed out"
+      exit 1
+    fi
+    if [ "$pingResponse" != "PONG"]; then
+      echo "$pingResponse"
+      exit 1
+    fi
+  liveness.sh: |-
+    #!/bin/sh
+    pingResponse="$(redis-cli -h localhost ping | head -n1 | awk '{print $1;}')"
+    if [ "$?" -eq "124" ]; then
+      echo "PING timed out"
+      exit 1
+    fi
+    if [ "$pingResponse" != "PONG"] && [ "$pingResponse" != "LOADING" ] && [ "$pingResponse" != "MASTERDOWN" ]; then
+      echo "$pingResponse"
+      exit 1
+    fi
\ No newline at end of file
diff --git a/zotprime-k8s/yamls/redis/zotprime-redis-service.yaml b/zotprime-k8s/yamls/redis/zotprime-redis-service.yaml
new file mode 100644
index 00000000..bfa4ed30
--- /dev/null
+++ b/zotprime-k8s/yamls/redis/zotprime-redis-service.yaml
@@ -0,0 +1,18 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: redis
+  namespace: zotprime
+#  annotations:
+#    service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
+  labels:
+    apps: redis
+spec:
+  ports:
+  - port: 6379
+    targetPort: 6379
+    name: redis
+  selector:
+    apps: zotprime-redis
+#  clusterIP: None
+#  publishNotReadyAddresses: True
diff --git a/zotprime-k8s/yamls/redis/zotprime-redis.yaml b/zotprime-k8s/yamls/redis/zotprime-redis.yaml
new file mode 100644
index 00000000..9b913d7d
--- /dev/null
+++ b/zotprime-k8s/yamls/redis/zotprime-redis.yaml
@@ -0,0 +1,100 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: zotprime-redis
+  namespace: zotprime
+  labels:
+    apps: zotprime-redis
+spec:
+  serviceName: "redis-service"
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-redis
+  template:
+    metadata:
+      labels:
+        apps: zotprime-redis
+#        appCluster: redis-cluster
+    spec:
+      terminationGracePeriodSeconds: 20
+      affinity:
+        podAntiAffinity:
+          preferredDuringSchedulingIgnoredDuringExecution:
+          - weight: 100
+            podAffinityTerm:
+              labelSelector:
+                matchExpressions:
+                - key: app
+                  operator: In
+                  values:
+                  - redis
+              topologyKey: kubernetes.io/hostname
+      containers:
+      - name: redis
+        image: "redis:5.0"
+        command:
+          - "redis-server"
+        args:
+          - "/conf/redis.conf"
+          - "--protected-mode"
+          - "no"
+        resources:
+          requests:
+            cpu: "100m"
+            memory: "100Mi"
+        ports:
+            - name: redis
+              containerPort: 6379
+              protocol: "TCP"
+            - name: cluster
+              containerPort: 16379
+              protocol: "TCP"
+        startupProbe:
+          periodSeconds: 5
+          timeoutSeconds: 5
+          successThreshold: 1
+          failureThreshold: 20
+          tcpSocket:
+            port: redis
+        livenessProbe:
+          periodSeconds: 5
+          timeoutSeconds: 5
+          successThreshold: 1
+          failureThreshold: 5
+          exec:
+            command: ["sh", "-c", "/probes/liveness.sh"]
+        readinessProbe:
+          periodSeconds: 5
+          timeoutSeconds: 1
+          successThreshold: 1
+          failureThreshold: 5
+          exec:
+            command: ["sh", "-c", "/probes/readiness.sh"]
+        volumeMounts:
+        - name: conf
+          mountPath: /conf
+          readOnly: false
+        - name: data
+          mountPath: /data
+          readOnly: false
+        - name: probes
+          mountPath: /probes
+          readOnly: true
+      volumes:
+      - name: conf
+        configMap:
+          name: redis-config
+          defaultMode: 0755
+      - name: probes
+        configMap:
+          name: redis-probes
+          defaultMode: 0555
+  volumeClaimTemplates:
+  - metadata:
+      name: data
+    spec:
+      accessModes: [ "ReadWriteOnce" ]
+      resources:
+        requests:
+          storage: 1Gi
\ No newline at end of file
diff --git a/zotprime-k8s/yamls/streamserver/configmap.yaml b/zotprime-k8s/yamls/streamserver/configmap.yaml
new file mode 100644
index 00000000..f2dd1e82
--- /dev/null
+++ b/zotprime-k8s/yamls/streamserver/configmap.yaml
@@ -0,0 +1,19 @@
+apiVersion: v1
+data:
+  default.js: "var os = require(\"os\");\n\n// Default config\nvar config = {\n\tdev:
+    false,\n\tlogLevel: 'info',\n\thostname: os.hostname().split('.')[0],\n\thttpPort:
+    81,\n\tproxyProtocol: false,\n\thttps: false,\n\ttrustedProxies: [],\n\tstatusInterval:
+    10,\n\tkeepaliveInterval: 25,\n\tretryTime: 10,\n\tshutdownDelay: 100,\n\tredis:
+    {\n\t\thost: 'redis',\n\t\tprefix: ''\n\t},\n\tapiURL: 'http://zotprime-dataserver/',\n\tapiVersion:
+    3,\n\tapiRequestHeaders: {},\n\tlongStackTraces: false,\n\tglobalTopics: [\n\t\t'styles',\n\t\t'translators'\n\t],\n\t//
+    Minimum delay before clients should act on global topic notifications -- since
+    these are triggered\n\t// by webhooks or other queued notifications, they need
+    time to be processed elsewhere\n\tglobalTopicsMinDelay: 30 * 1000,\n\t// Notification
+    action period -- clients are given a randomly chosen delay within this time\n\t//
+    period before they should act upon the notification, so that we don't DDoS ourselves\n\tglobalTopicsDelayPeriod:
+    60 * 1000,\n\tcontinuedDelayDefault: 3 * 1000,\n\tcontinuedDelay: 30 * 1000,\n\tstatsD:
+    {\n\t\thost: ''\n\t}\n};\n\nmodule.exports = config;\n"
+kind: ConfigMap
+metadata:
+  name: streamserver-config
+  namespace: zotprime
\ No newline at end of file
diff --git a/zotprime-k8s/yamls/streamserver/zotprime-streamserver-service.yaml b/zotprime-k8s/yamls/streamserver/zotprime-streamserver-service.yaml
new file mode 100644
index 00000000..5e0b97fc
--- /dev/null
+++ b/zotprime-k8s/yamls/streamserver/zotprime-streamserver-service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: streamserver
+  name: streamserver
+  namespace: zotprime
+spec:
+  ports:
+    - name: port-8081
+      port: 8081
+      targetPort: 81
+  selector:
+    apps: zotprime-streamserver
+  type: ClusterIP
+#status:
+#  loadBalancer: {}
diff --git a/zotprime-k8s/yamls/streamserver/zotprime-streamserver.yaml b/zotprime-k8s/yamls/streamserver/zotprime-streamserver.yaml
new file mode 100644
index 00000000..1b66e5a3
--- /dev/null
+++ b/zotprime-k8s/yamls/streamserver/zotprime-streamserver.yaml
@@ -0,0 +1,40 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    apps: zotprime-streamserver
+  name: zotprime-streamserver
+  namespace: zotprime
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      apps: zotprime-streamserver
+  strategy: {}
+  template:
+    metadata:
+      labels:
+        apps: zotprime-streamserver
+      name: zotprime-streamserver
+    spec:
+      containers:
+        - image: localhost:32000/zotprime-k8s/zotprime-streamserver:latest
+          imagePullPolicy: Always
+          name: zotprime-streamserver
+          ports:
+            - containerPort: 81
+          volumeMounts:
+            - name: streamserver-config
+              mountPath: /usr/src/app/config/
+              readOnly: true              
+          resources:
+            limits:
+              memory: 256Mi
+              cpu: "0.1"
+      volumes:
+      - name: streamserver-config
+        configMap:
+          name: streamserver-config          
+      restartPolicy: Always
+      securityContext: {}
+status: {}
diff --git a/zotprime-k8s/yamls/tinymceclean/zotprime-tinymceclean-service.yaml b/zotprime-k8s/yamls/tinymceclean/zotprime-tinymceclean-service.yaml
new file mode 100644
index 00000000..60a75874
--- /dev/null
+++ b/zotprime-k8s/yamls/tinymceclean/zotprime-tinymceclean-service.yaml
@@ -0,0 +1,17 @@
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    apps: tinymceclean
+  name: tinymceclean
+  namespace: zotprime
+spec:
+  ports:
+    - name: port-16342
+      port: 16342
+      targetPort: 16342
+  selector:
+    apps: zotprime-tinymceclean
+  type: ClusterIP
+#status:
+#  loadBalancer: {}
" . $note . "