diff --git a/html/ops/fix_project_prefs.php b/html/ops/fix_project_prefs.php
new file mode 100755
index 0000000000..84756b0c68
--- /dev/null
+++ b/html/ops/fix_project_prefs.php
@@ -0,0 +1,210 @@
+#!/usr/bin/env php
+.
+
+// repair user.project_prefs field (does not validate project_prefs of all users!)
+// * squash duplicate app_id entries (and optionally delete deprecated appids)
+// * delete empty venue tags
+// * migrate preset from attribute to tag (Drupal specific)
+//
+// produces lots of output in dry_run mode so always pipe it through less
+
+require_once("../inc/boinc_db.inc");
+
+// check your scheduler code if is supported before turning this on
+define('ADD_APPS_SELECTED_BLOCK', false);
+// removes all currently deprecated apps from project_prefs
+define('DELETE_DEPRECATED_APPS', false);
+
+// test on all users first then change to false!
+$dry_run = true;
+
+// delete duplicate app_id entries and delete old ones
+// returns an array of unique app_id entries
+//
+function clean_appids($appids) {
+ $apps = BoincApp::enum("deprecated=0");
+ $current_appids = array();
+ foreach ($apps as $app) {
+ $current_appids[] = $app->id;
+ }
+ $uniq = array_unique($appids);
+ sort($uniq);
+ if (DELETE_DEPRECATED_APPS) {
+ $uniq = array_intersect($uniq, $current_appids);
+ }
+ return $uniq;
+}
+
+// extracts all app_id elements below $node, removes them
+// and adds only the elements that remain after cleaning
+// may add a surrounding apps_selected block if needed
+//
+function handle_project_specific_block($node) {
+ $asnode = $node->getElementsByTagName("apps_selected");
+ $length = $asnode->length;
+ if ($length == 1) {
+ $node = $asnode[0];
+ } else if ($length == 0) {
+ if (ADD_APPS_SELECTED_BLOCK) {
+ // create a new apps_selected block and move app_id tags in there
+ $asnode = $node->ownerDocument->createElement('apps_selected');
+ $asnode = $node->appendChild($asnode);
+ $appids = $node->getElementsByTagName("app_id");
+ // changing the tree in a loop has strange results so do it separately
+ $to_remove = array();
+ $ids = array();
+ foreach($appids as $elem) {
+ $ids[] = $elem->nodeValue;
+ $to_remove[] = $elem;
+ }
+ foreach ($to_remove as $elem) {
+ $oldnode = $node->removeChild($elem);
+ }
+ foreach ($ids as $id) {
+ $elem = $node->ownerDocument->createElement('app_id', $id);
+ $asnode->appendChild($elem);
+ }
+ $node = $asnode;
+ }
+ } else {
+ echo "more than one apps_selected block found; Exiting\n";
+ exit(1);
+ }
+ $appids = $node->getElementsByTagName("app_id");
+ if ($appids->length == 0 ) return;
+ // changing the tree in a loop has strange results so do it separately
+ $to_remove = array();
+ $ids = array();
+ foreach ($appids as $elem) {
+ $ids[] = $elem->nodeValue;
+ $to_remove[] = $elem;
+ }
+ foreach ($to_remove as $elem) {
+ $oldnode = $node->removeChild($elem);
+ }
+ $cleaned = clean_appids($ids);
+ foreach ($cleaned as $id) {
+ $elem = $node->ownerDocument->createElement('app_id', $id);
+ $node->appendChild($elem);
+ }
+}
+
+// read project specific prefs into a DOMDocument tree and find nodes to fix
+// returns fixed XML or false if something failed
+function repair_prefs($prefs) {
+ $prefs_dom = new DOMDocument();
+ $prefs_dom->preserveWhiteSpace = false;
+ $prefs_dom->formatOutput = true;
+ $prefs_dom->validateOnParse = true;
+ if (!$prefs_dom->loadXML($prefs)) {
+ return false;
+ }
+
+ $root = $prefs_dom->firstChild;
+ if ($root->hasChildNodes()) {
+ $subNodes = $root->childNodes;
+ $to_remove = array();
+ foreach ($subNodes as $subNode) {
+ // ignore empty text nodes which are basically whitespace from indentation (only relevant if preserveWhiteSpace is set to true above)
+ if (($subNode->nodeType != XML_TEXT_NODE) || (($subNode->nodeType == XML_TEXT_NODE) &&(strlen(trim($subNode->wholeText))>=1))) {
+ if ($subNode->nodeName == "venue") {
+ if (!$subNode->hasChildNodes()) {
+ // empty venue tag, clean this up too
+ $to_remove[] = $subNode;
+ echo "warning: empty venue tag removed\n";
+ continue;
+ }
+ if ($subNode->hasAttributes()) {
+ // transform old style preset attributes to tag
+ $preset = $subNode->getAttribute("preset");
+ if ($preset != "") {
+ $subNode->removeAttribute("preset");
+ $elem = $subNode->ownerDocument->createElement('preset', $preset);
+ $subNode->appendChild($elem);
+ echo "warning: transformed preset attribute to tag\n";
+ }
+ }
+ // check if venue has a project_specific subnode (its optional)
+ $psnode = $subNode->getElementsByTagName("project_specific");
+ if ($psnode->length == 1) {
+ handle_project_specific_block($psnode[0]);
+ }
+ }
+ if ($subNode->nodeName == "project_specific") {
+ handle_project_specific_block($subNode);
+ }
+ }
+ }
+ foreach ($to_remove as $elem) {
+ $oldnode = $root->removeChild($elem);
+ }
+ }
+
+ return $prefs_dom->saveXML($prefs_dom->documentElement);
+}
+
+function process_set($users) {
+ global $dry_run;
+ foreach ($users as $user) {
+ if (!$user->project_prefs) {
+ //echo "repair not needed for user $user->id\n";
+ continue;
+ }
+ // only parse XML if it contains something we want to fix
+ if (strstr($user->project_prefs, "app_id")
+ || strstr($user->project_prefs, "preset=")) {
+
+ echo "repair started for user $user->id\n";
+ $p = repair_prefs($user->project_prefs);
+ if ($p) {
+ if (!$dry_run) {
+ if ($user->update("project_prefs='$p'")) {
+ echo "project_prefs repaired and updated for user $user->id\n";
+ } else {
+ echo "failed to update project_prefs for user $user->id; Exiting\n";
+ exit(1);
+ }
+ } else {
+ echo "repair succeeded for user $user->id\n";
+ echo "repaired prefs:\n$p\n";
+ }
+ } else {
+ echo "repair failed for user $user->id\n";
+ if ($dry_run) {
+ echo "original prefs:\n$user->project_prefs\n";
+ }
+ }
+ }
+ }
+}
+
+$n = 0;
+$maxid = BoincUser::max("id");
+if ($dry_run) {
+ echo "Dry run only! No preferences will be updated\n";
+}
+while ($n <= $maxid) {
+ $m = $n + 1000;
+ $users = BoincUser::enum("id >= $n and id < $m");
+ //echo "processing from $n\n";
+ if (!$users) break;
+ process_set($users);
+ $n = $m;
+}
+?>