| [ Index ] |
PHP Cross Reference of Drupal 6 (yi-drupal) |
[Summary view] [Print] [Text view]
1 <?php 2 // $Id: migrate.module,v 1.1.2.62 2009/09/19 16:23:57 mikeryan Exp $ 3 4 define('MIGRATE_ACCESS_BASIC', 'basic migration tools'); 5 define('MIGRATE_ACCESS_ADVANCED', 'advanced migration tools'); 6 7 define('MIGRATE_MESSAGE_ERROR', 1); 8 define('MIGRATE_MESSAGE_WARNING', 2); 9 define('MIGRATE_MESSAGE_NOTICE', 3); 10 define('MIGRATE_MESSAGE_INFORMATIONAL', 4); 11 12 define('MIGRATE_STATUS_SUCCESS', 1); 13 define('MIGRATE_STATUS_FAILURE', 2); 14 define('MIGRATE_STATUS_TIMEDOUT', 3); 15 define('MIGRATE_STATUS_CANCELLED', 4); 16 define('MIGRATE_STATUS_IN_PROGRESS', 5); 17 18 /** 19 * @file 20 * This module provides tools at "administer >> content >> migrate" 21 * for analyzing data from various sources and importing them into Drupal tables. 22 */ 23 24 /** 25 * Call a migrate hook 26 */ 27 function migrate_invoke_all($hook) { 28 // Let modules do any one-time initialization (e.g., including migration support files) 29 module_invoke_all('migrate_init'); 30 $args = func_get_args(); 31 $hookfunc = "migrate" . "_$hook"; 32 unset($args[0]); 33 $return = array(); 34 $modulelist = module_implements($hookfunc); 35 foreach ($modulelist as $module) { 36 $function = $module . '_' . $hookfunc; 37 $result = call_user_func_array($function, $args); 38 if (isset($result) && is_array($result)) { 39 $return = array_merge_recursive($return, $result); 40 } 41 elseif (isset($result)) { 42 $return[] = $result; 43 } 44 } 45 return $return; 46 } 47 48 /** 49 * Call a destination hook (e.g., hook_migrate_prepare_node). Use this version 50 * for hooks with the precise signature below, so that the object can be passed by 51 * reference. 52 * 53 * @param $hook 54 * @param $object 55 * @param $tblinfo 56 * @param $row 57 * @return 58 */ 59 function migrate_destination_invoke_all($hook, &$object, $tblinfo, $row) { 60 // We could have used module_invoke_all, but unfortunately 61 // module_invoke_all passes all arguments by value. 62 $errors = array(); 63 $hook = 'migrate_' . $hook; 64 foreach (module_implements($hook) as $module_name) { 65 $function = $module_name . '_' . $hook; 66 if (function_exists($function)) { 67 timer_start($function); 68 $errors = array_merge($errors, (array)$function($object, $tblinfo, $row)); 69 timer_stop($function); 70 } 71 } 72 return $errors; 73 } 74 75 /** 76 * Save a new or updated content set 77 * 78 * @param $content_set 79 * An array or object representing the content set. This is passed by reference (so 80 * when adding a new content set the ID can be set) 81 * @param $options 82 * Array of additional options for saving the content set. Currently: 83 * base_table: The base table of the view - if provided, we don't need 84 * to load the view. 85 * base_database: The database of the base table - if base_table is present 86 * and base_database omitted, it defaults to 'default' 87 * @return 88 * The ID of the content set that was saved, or NULL if nothing was saved 89 */ 90 function migrate_save_content_set(&$content_set, $options = array()) { 91 // Deal with objects internally (but remember if we need to put the parameter 92 // back to an array) 93 if (is_array($content_set)) { 94 $was_array = TRUE; 95 $content_set = (object) $content_set; 96 } 97 else { 98 $was_array = FALSE; 99 } 100 101 // Update or insert the content set record as appropriate 102 if (isset($content_set->mcsid)) { 103 drupal_write_record('migrate_content_sets', $content_set, 'mcsid'); 104 } 105 else { 106 drupal_write_record('migrate_content_sets', $content_set); 107 } 108 109 // Create or modify map and message tables 110 $maptablename = migrate_map_table_name($content_set->mcsid); 111 $msgtablename = migrate_message_table_name($content_set->mcsid); 112 113 // TODO: For now, PK must be in base_table 114 115 // If the caller tells us the base table of the view, we don't need 116 // to load the view (which would not work when called from hook_install()) 117 if (isset($options['base_table'])) { 118 $tablename = $options['base_table']; 119 if (isset($options['base_database'])) { 120 $tabledb = $options['base_database']; 121 } 122 else { 123 $tabledb = 'default'; 124 } 125 } 126 else { 127 // Get the proper field definition for the sourcekey 128 $view = views_get_view($content_set->view_name); 129 if (!$view) { 130 drupal_set_message(t('View !view does not exist - either (re)create this view, or 131 remove the content set using it.', array('!view' => $content_set->view_name))); 132 return NULL; 133 } 134 // Must do this to load the database 135 $view->init_query(); 136 137 if (isset($view->base_database)) { 138 $tabledb = $view->base_database; 139 } 140 else { 141 $tabledb = 'default'; 142 } 143 $tablename = $view->base_table; 144 } 145 146 db_set_active($tabledb); 147 $inspect = schema_invoke('inspect'); 148 db_set_active('default'); 149 $sourceschema = $inspect[$tablename]; 150 // If the PK of the content set is defined, make sure we have a mapping table 151 if (isset($content_set->sourcekey) && $content_set->sourcekey) { 152 $sourcefield = $sourceschema['fields'][$content_set->sourcekey]; 153 // The field name might be <table>_<column>... 154 if (!$sourcefield) { 155 $sourcekey = drupal_substr($content_set->sourcekey, drupal_strlen($tablename) + 1); 156 $sourcefield = $sourceschema['fields'][$sourcekey]; 157 } 158 // But - we don't want serial fields to behave serially, so change to int 159 if ($sourcefield['type'] == 'serial') { 160 $sourcefield['type'] = 'int'; 161 } 162 163 $schema_change = FALSE; 164 165 if (!db_table_exists($maptablename)) { 166 $schema = _migrate_map_table_schema($sourcefield); 167 db_create_table($ret, $maptablename, $schema); 168 // Expose map table to views 169 tw_add_tables(array($maptablename)); 170 tw_add_fk($maptablename, 'destid'); 171 172 $schema = _migrate_message_table_schema($sourcefield); 173 db_create_table($ret, $msgtablename, $schema); 174 // Expose messages table to views 175 tw_add_tables(array($msgtablename)); 176 tw_add_fk($msgtablename, 'sourceid'); 177 $schema_change = TRUE; 178 } 179 else { 180 // TODO: Deal with varchar->int case where there is existing non-int data 181 $desired_schema = _migrate_map_table_schema($sourcefield); 182 $actual_schema = $inspect[$maptablename]; 183 if ($desired_schema['fields']['sourceid'] != $actual_schema['fields']['sourceid']) { 184 $ret = array(); 185 db_drop_primary_key($ret, $maptablename); 186 db_change_field($ret, $maptablename, 'sourceid', 'sourceid', 187 $sourcefield, array('primary key' => array('sourceid'))); 188 tw_perform_analysis($maptablename); 189 $schema_change = TRUE; 190 } 191 $desired_schema = _migrate_message_table_schema($sourcefield); 192 $actual_schema = $inspect[$msgtablename]; 193 if ($desired_schema['fields']['sourceid'] != $actual_schema['fields']['sourceid']) { 194 $ret = array(); 195 db_drop_index($ret, $msgtablename, 'sourceid'); 196 db_change_field($ret, $msgtablename, 'sourceid', 'sourceid', 197 $sourcefield, array('indexes' => array('sourceid' => array('sourceid')))); 198 tw_perform_analysis($maptablename); 199 $schema_change = TRUE; 200 } 201 } 202 // Make sure the schema gets updated to reflect changes 203 if ($schema_change) { 204 cache_clear_all('schema', 'cache'); 205 } 206 } 207 208 if ($was_array) { 209 $content_set = (array)$content_set; 210 return $content_set['mcsid']; 211 } 212 else { 213 return $content_set->mcsid; 214 } 215 } 216 217 function migrate_save_content_mapping(&$mapping) { 218 if ($mapping->mcmid) { 219 drupal_write_record('migrate_content_mappings', $mapping, 'mcmid'); 220 } 221 else { 222 drupal_write_record('migrate_content_mappings', $mapping); 223 } 224 return $mapping->mcmid; 225 } 226 227 function migrate_delete_content_set($mcsid) { 228 // First, remove the map and message tables from the Table Wizard, and drop them 229 $ret = array(); 230 $maptable = migrate_map_table_name($mcsid); 231 $msgtable = migrate_message_table_name($mcsid); 232 if (db_table_exists($maptable)) { 233 tw_remove_tables(array($maptable, $msgtable)); 234 db_drop_table($ret, $maptable); 235 db_drop_table($ret, $msgtable); 236 } 237 238 // Then, delete the content set data 239 $sql = "DELETE FROM {migrate_content_mappings} WHERE mcsid=%d"; 240 db_query($sql, $mcsid); 241 $sql = "DELETE FROM {migrate_content_sets} WHERE mcsid=%d"; 242 db_query($sql, $mcsid); 243 } 244 245 function migrate_delete_content_mapping($mcmid) { 246 $sql = "DELETE FROM {migrate_content_mappings} WHERE mcmid=%d"; 247 db_query($sql, $mcmid); 248 } 249 250 /** 251 * Convenience function for generating a message array 252 * 253 * @param $message 254 * Text describing the error condition 255 * @param $type 256 * One of the MIGRATE_MESSAGE constants, identifying the level of error 257 * @return 258 * Structured array suitable for return from an import hook 259 */ 260 function migrate_message($message, $type = MIGRATE_MESSAGE_ERROR) { 261 $error = array( 262 'level' => $type, 263 'message' => $message, 264 ); 265 return $error; 266 } 267 268 /** 269 * Add a mapping from source ID to destination ID for the specified content set 270 * 271 * @param $mcsid 272 * ID of the content set being processed 273 * @param $sourceid 274 * Primary key value from the source 275 * @param $destid 276 * Primary key value from the destination 277 */ 278 function migrate_add_mapping($mcsid, $sourceid, $destid) { 279 static $maptables = array(); 280 if (!isset($maptables[$mcsid])) { 281 $maptables[$mcsid] = migrate_map_table_name($mcsid); 282 } 283 $mapping = new stdClass; 284 $mapping->sourceid = $sourceid; 285 $mapping->destid = $destid; 286 drupal_write_record($maptables[$mcsid], $mapping); 287 } 288 289 /** 290 * Clear migrated objects from the specified content set 291 * 292 * @param $mcsid 293 * ID of the content set to clear 294 * @param $messages 295 * Array of messages to (ultimately) be displayed by the caller. 296 * @param $options 297 * Keyed array of optional options: 298 * itemlimit - Maximum number of items to process 299 * timelimit - Unix timestamp after which to stop processing 300 * idlist - Comma-separated list of source IDs to process, instead of proceeding through 301 * all unmigrated rows 302 * feedback - Keyed array controlling status feedback to the caller 303 * function - PHP function to call, passing a message to be displayed 304 * frequency - How often to call the function 305 * frequency_unit - How to interpret frequency (items or seconds) 306 * 307 * @return 308 * Status of the migration process: 309 */ 310 function migrate_content_process_clear($mcsid, &$messages = array(), &$options = array()) { 311 $itemlimit = $options['itemlimit']; 312 $timelimit = $options['timelimit']; 313 $idlist = $options['idlist']; 314 $lastfeedback = time(); 315 if (isset($options['feedback'])) { 316 $feedback = $options['feedback']['function']; 317 $frequency = $options['feedback']['frequency']; 318 $frequency_unit = $options['feedback']['frequency_unit']; 319 } 320 321 $result = db_query("SELECT * 322 FROM {migrate_content_sets} 323 WHERE mcsid=%d", $mcsid); 324 $tblinfo = db_fetch_object($result); 325 $description = $tblinfo->description; 326 if ($tblinfo->semaphore) { 327 $messages[] = t('Content set !content_set has an operation already in progress.', 328 array('!content_set' => $description)); 329 return MIGRATE_STATUS_IN_PROGRESS; 330 } 331 else { 332 $tblinfo->semaphore = TRUE; 333 drupal_write_record('migrate_content_sets', $tblinfo, 'mcsid'); 334 } 335 $desttype = $tblinfo->desttype; 336 $view_name = $tblinfo->view_name; 337 $contenttype = $tblinfo->contenttype; 338 $sourcekey = $tblinfo->sourcekey; 339 340 $maptable = migrate_map_table_name($mcsid); 341 $msgtablename = migrate_message_table_name($mcsid); 342 $processstart = microtime(TRUE); 343 $status = MIGRATE_STATUS_IN_PROGRESS; 344 345 // If we're being called on a content set that isn't flagged for clearing, temporarily flag it 346 $original_clearing = $tblinfo->clearing; 347 if (!$original_clearing) { 348 $sql = "UPDATE {migrate_content_sets} SET clearing=1 WHERE mcsid=%d"; 349 db_query($sql, $mcsid); 350 } 351 352 $deleted = 0; 353 if ($idlist) { 354 $sql = "SELECT sourceid,destid FROM {" . $maptable . "} WHERE sourceid IN ($idlist)"; 355 } 356 else { 357 $sql = "SELECT sourceid,destid FROM {" . $maptable . "}"; 358 } 359 360 timer_start('delete query'); 361 if ($itemlimit) { 362 $deletelist = db_query_range($sql, 0, $itemlimit); 363 } 364 else { 365 $deletelist = db_query($sql); 366 } 367 timer_stop('delete query'); 368 369 while ($row = db_fetch_object($deletelist)) { 370 // Recheck clearing flag - permits dynamic interruption of jobs 371 $sql = "SELECT clearing FROM {migrate_content_sets} WHERE mcsid=%d"; 372 $clearing = db_result(db_query($sql, $mcsid)); 373 if (!$clearing) { 374 $status = MIGRATE_STATUS_CANCELLED; 375 break; 376 } 377 // Check for time out if there is time info present 378 if (isset($timelimit) && time() >= $timelimit) { 379 $status = MIGRATE_STATUS_TIMEDOUT; 380 break; 381 } 382 if (isset($feedback)) { 383 if (($frequency_unit == 'seconds' && time()-$lastfeedback >= $frequency) || 384 ($frequency_unit == 'items' && $deleted >= $frequency)) { 385 $message = _migrate_progress_message($lastfeedback, $deleted, $description, FALSE, $status); 386 $feedback($message); 387 $lastfeedback = time(); 388 $deleted = 0; 389 } 390 } 391 // @TODO: Should return success/failure. Problem: node_delete doesn't return anything... 392 migrate_invoke_all("delete_$contenttype", $row->destid); 393 timer_start('clear map/msg'); 394 db_query("DELETE FROM {" . $maptable . "} WHERE sourceid=%d", $row->sourceid); 395 db_query("DELETE FROM {" . $msgtablename . "} WHERE sourceid=%d AND level=%d", 396 $row->sourceid, MIGRATE_MESSAGE_INFORMATIONAL); 397 timer_stop('clear map/msg'); 398 $deleted++; 399 } 400 401 if ($status == MIGRATE_STATUS_IN_PROGRESS) { 402 $status = MIGRATE_STATUS_SUCCESS; 403 } 404 405 $message = _migrate_progress_message($lastfeedback, $deleted, $description, FALSE, $status); 406 if ($status == MIGRATE_STATUS_SUCCESS) { 407 // Mark that we're done 408 $tblinfo->clearing = 0; 409 drupal_write_record('migrate_content_sets', $tblinfo, 'mcsid'); 410 // Remove old messages before beginning new import process 411 db_query("DELETE FROM {" . $msgtablename . "} WHERE level <> %d", MIGRATE_MESSAGE_INFORMATIONAL); 412 } 413 if (isset($feedback)) { 414 $feedback($message); 415 } 416 else { 417 $messages[] = $message; 418 } 419 watchdog('migrate', $message); 420 if (!$original_clearing) { 421 $sql = "UPDATE {migrate_content_sets} SET clearing=0 WHERE mcsid=%d"; 422 db_query($sql, $mcsid); 423 } 424 425 $tblinfo->semaphore = FALSE; 426 drupal_write_record('migrate_content_sets', $tblinfo, 'mcsid'); 427 428 return $status; 429 } 430 431 /** 432 * Import objects from the specified content set 433 * 434 * @param $mcsid 435 * ID of the content set to clear 436 * @param $messages 437 * Array of messages to (ultimately) be displayed by the caller. 438 * @param $options 439 * Keyed array of optional options: 440 * itemlimit - Maximum number of items to process 441 * timelimit - Unix timestamp after which to stop processing 442 * idlist - Comma-separated list of source IDs to process, instead of proceeding through 443 * all unmigrated rows 444 * feedback - Keyed array controlling status feedback to the caller 445 * function - PHP function to call, passing a message to be displayed 446 * frequency - How often to call the function 447 * frequency_unit - How to interpret frequency (items or seconds) 448 * 449 * @return 450 * Status of the migration process: 451 */ 452 function migrate_content_process_import($mcsid, &$messages = array(), &$options = array()) { 453 $itemlimit = $options['itemlimit']; 454 $timelimit = $options['timelimit']; 455 $idlist = $options['idlist']; 456 $lastfeedback = time(); 457 if (isset($options['feedback'])) { 458 $feedback = $options['feedback']['function']; 459 $frequency = $options['feedback']['frequency']; 460 $frequency_unit = $options['feedback']['frequency_unit']; 461 } 462 $result = db_query("SELECT * 463 FROM {migrate_content_sets} 464 WHERE mcsid=%d", $mcsid); 465 $tblinfo = db_fetch_object($result); 466 $description = $tblinfo->description; 467 if ($tblinfo->semaphore) { 468 $messages[] = t('Content set !content_set has an operation already in progress.', 469 array('!content_set' => $description)); 470 return MIGRATE_STATUS_IN_PROGRESS; 471 } 472 else { 473 $tblinfo->semaphore = TRUE; 474 drupal_write_record('migrate_content_sets', $tblinfo, 'mcsid'); 475 } 476 $desttype = $tblinfo->desttype; 477 $view_name = $tblinfo->view_name; 478 $contenttype = $tblinfo->contenttype; 479 $sourcekey = $tblinfo->sourcekey; 480 481 $maptable = migrate_map_table_name($mcsid); 482 $msgtablename = migrate_message_table_name($mcsid); 483 $processstart = microtime(TRUE); 484 $status = MIGRATE_STATUS_IN_PROGRESS; 485 486 // If we're being called on a content set that isn't flagged for importing, temporarily flag it 487 $original_importing = $tblinfo->importing || $tblinfo->scanning; 488 if (!$original_importing) { 489 $sql = "UPDATE {migrate_content_sets} SET importing=1 WHERE mcsid=%d"; 490 db_query($sql, $mcsid); 491 } 492 493 $collist = db_query("SELECT srcfield, destfield, default_value 494 FROM {migrate_content_mappings} 495 WHERE mcsid=%d AND (srcfield <> '' OR default_value <> '') 496 ORDER BY mcmid", 497 $mcsid); 498 $fields = array(); 499 while ($row = db_fetch_object($collist)) { 500 $fields[$row->destfield]['srcfield'] = $row->srcfield; 501 $fields[$row->destfield]['default_value'] = $row->default_value; 502 } 503 $tblinfo->fields = $fields; 504 $tblinfo->maptable = $maptable; 505 // We pick up everything in the input view that is not already imported, and 506 // not already errored out 507 // Emulate views execute(), so we can scroll through the results ourselves 508 $view = views_get_view($view_name); 509 if (!$view) { 510 $messages[] = t('View !view does not exist - either (re)create this view, or 511 remove the content set using it.', array('!view' => $view_name)); 512 return MIGRATE_STATUS_FAILURE; 513 } 514 $view->build(); 515 516 // Let modules modify the view just prior to executing it. 517 foreach (module_implements('views_pre_execute') as $module) { 518 $function = $module . '_views_pre_execute'; 519 $function($view); 520 } 521 522 $viewdb = $view->base_database; 523 524 // Add a left join to the map table, and only include rows not in the map 525 $join = new views_join; 526 527 // Views prepends <base_table>_ to column names other than the base table's 528 // primary key - we need to strip that here for the join to work. But, it's 529 // common for tables to have the tablename beginning field names (e.g., 530 // table cms with PK cms_id). Deal with that as well... 531 $baselen = drupal_strlen($view->base_table); 532 if (!strncasecmp($sourcekey, $view->base_table . '_', $baselen + 1)) { 533 // So, which case is it? Ask the schema module... 534 db_set_active($viewdb); 535 $inspect = schema_invoke('inspect', db_prefix_tables('{'. $view->base_table .'}')); 536 db_set_active('default'); 537 $tableschema = $inspect[$view->base_table]; 538 $sourcefield = $tableschema['fields'][$sourcekey]; 539 if (!$sourcefield) { 540 $joinkey = drupal_substr($sourcekey, $baselen + 1); 541 $sourcefield = $tableschema['fields'][$joinkey]; 542 if (!$sourcefield) { 543 $messages[] = t("In view !view, can't find key !key for table !table", 544 array('!view' => $view_name, '!key' => $sourcekey, '!table' => $view->base_table)); 545 return MIGRATE_STATUS_FAILURE; 546 } 547 } 548 else { 549 $joinkey = $sourcekey; 550 } 551 } 552 else { 553 $joinkey = $sourcekey; 554 } 555 $join->construct($maptable, $view->base_table, $joinkey, 'sourceid'); 556 $view->query->add_relationship($maptable, $join, $view->base_table); 557 $view->query->add_where(0, "$maptable.sourceid IS NULL", $view->base_table); 558 559 // Ditto for the errors table 560 $join = new views_join; 561 562 $join->construct($msgtablename, $view->base_table, $joinkey, 'sourceid'); 563 $view->query->add_relationship($msgtablename, $join, $view->base_table); 564 $view->query->add_where(0, "$msgtablename.sourceid IS NULL", $view->base_table); 565 566 // If running over a selected list of IDs, pass those in to the query 567 if ($idlist) { 568 $view->query->add_where($view->options['group'], $view->base_table . ".$sourcekey IN ($idlist)", 569 $view->base_table); 570 } 571 572 // We can't seem to get $view->build() to rebuild build_info, so go straight into the query object 573 $query = $view->query->query(); 574 575 $query = db_rewrite_sql($query, $view->base_table, $view->base_field, 576 array('view' => &$view)); 577 $args = $view->build_info['query_args']; 578 $replacements = module_invoke_all('views_query_substitutions', $view); 579 $query = str_replace(array_keys($replacements), $replacements, $query); 580 if (is_array($args)) { 581 foreach ($args as $id => $arg) { 582 $args[$id] = str_replace(array_keys($replacements), $replacements, $arg); 583 } 584 } 585 586 // Now, make the current db name explicit if content set is pulling tables from another DB 587 if ($viewdb <> 'default') { 588 global $db_url; 589 $url = parse_url(is_array($db_url) ? $db_url['default'] : $db_url); 590 $currdb = drupal_substr($url['path'], 1); 591 $query = str_replace('{' . $maptable . '}', 592 $currdb . '.' . '{' . $maptable . '}', $query); 593 $query = str_replace('{' . $msgtablename . '}', 594 $currdb . '.' . '{' . $msgtablename . '}', $query); 595 db_set_active($viewdb); 596 } 597 598 //drupal_set_message($query); 599 timer_start('execute view query'); 600 if ($itemlimit) { 601 $importlist = db_query_range($query, $args, 0, $itemlimit); 602 } 603 else { 604 $importlist = db_query($query, $args); 605 } 606 timer_stop('execute view query'); 607 608 if ($viewdb != 'default') { 609 db_set_active('default'); 610 } 611 612 $imported = 0; 613 timer_start('db_fetch_object'); 614 while ($row = db_fetch_object($importlist)) { 615 timer_stop('db_fetch_object'); 616 // Recheck importing flag - permits dynamic interruption of cron jobs 617 $sql = "SELECT importing,scanning FROM {migrate_content_sets} WHERE mcsid=%d"; 618 $checkrow = db_fetch_object(db_query($sql, $mcsid)); 619 $importing = $checkrow->importing; 620 $scanning = $checkrow->scanning; 621 if (!($importing || $scanning)) { 622 $status = MIGRATE_STATUS_CANCELLED; 623 break; 624 } 625 626 // Check for time out if there is time info present 627 if (isset($timelimit) && time() >= $timelimit) { 628 $status = MIGRATE_STATUS_TIMEDOUT; 629 break; 630 } 631 632 if (isset($feedback)) { 633 if (($frequency_unit == 'seconds' && time()-$lastfeedback >= $frequency) || 634 ($frequency_unit == 'items' && $imported >= $frequency)) { 635 $message = _migrate_progress_message($lastfeedback, $imported, $description, TRUE, $status); 636 $feedback($message); 637 $lastfeedback = time(); 638 $imported = 0; 639 } 640 } 641 timer_start('import hooks'); 642 $errors = migrate_invoke_all("import_$contenttype", $tblinfo, $row); 643 timer_stop('import hooks'); 644 645 // Ok, we're done. Preview the node or save it (if no errors). 646 if (count($errors)) { 647 $success = TRUE; 648 foreach ($errors as $error) { 649 if (!isset($error['level'])) { 650 $error['level'] = MIGRATE_MESSAGE_ERROR; 651 } 652 if ($error['level'] != MIGRATE_MESSAGE_INFORMATIONAL) { 653 $success = FALSE; 654 } 655 db_query("INSERT INTO {" . $msgtablename . "} 656 (sourceid, level, message) 657 VALUES('%s', %d, '%s')", 658 $row->$sourcekey, $error['level'], $error['message']); 659 } 660 if ($success) { 661 $imported++; 662 } 663 } 664 else { 665 $imported++; 666 } 667 timer_start('db_fetch_object'); 668 } 669 timer_stop('db_fetch_object'); 670 671 if ($status == MIGRATE_STATUS_IN_PROGRESS) { 672 $status = MIGRATE_STATUS_SUCCESS; 673 } 674 $message = _migrate_progress_message($lastfeedback, $imported, $description, TRUE, $status); 675 if ($status == MIGRATE_STATUS_SUCCESS) { 676 // Remember we're done 677 if ($importing) { 678 $tblinfo->importing = 0; 679 } 680 $tblinfo->lastimported = date('Y-m-d H:i:s'); 681 } 682 if (isset($feedback)) { 683 $feedback($message); 684 } 685 else { 686 $messages[] = $message; 687 } 688 watchdog('migrate', $message); 689 if (!$original_importing) { 690 $sql = "UPDATE {migrate_content_sets} SET importing=0 WHERE mcsid=%d"; 691 db_query($sql, $mcsid); 692 } 693 694 $tblinfo->semaphore = FALSE; 695 drupal_write_record('migrate_content_sets', $tblinfo, 'mcsid'); 696 697 return $status; 698 } 699 700 /* Revisit 701 function migrate_content_process_all_action(&$dummy, $action_context, $a1, $a2) { 702 migrate_content_process_all(time()); 703 } 704 */ 705 706 function migrate_content_process_all_batch($starttime, $limit, $idlist, &$context) { 707 $messages = array(); 708 709 // A zero max_execution_time means no limit - but let's set a reasonable 710 // limit anyway 711 $starttime = time(); 712 $maxexectime = ini_get('max_execution_time'); 713 if (!$maxexectime) { 714 $maxexectime = 240; 715 } 716 717 // Initialize the Batch API context 718 $context['finished'] = 0; 719 720 // The Batch API progress bar will reflect the number of operations being 721 // done (clearing/importing/scanning) 722 if (!isset($context['sandbox']['numops'])) { 723 $numops = 0; 724 $sql = "SELECT COUNT(*) FROM {migrate_content_sets} WHERE clearing=1"; 725 $numops = db_result(db_query($sql)); 726 $sql = "SELECT COUNT(*) FROM {migrate_content_sets} WHERE importing=1 OR scanning=1"; 727 $numops += db_result(db_query($sql)); 728 $context['sandbox']['numops'] = $numops; 729 $context['sandbox']['numopsdone'] = 0; 730 } 731 732 // For the timelimit, subtract more than enough time to clean up 733 $options = array( 734 'itemlimit' => $limit, 735 'timelimit' => $starttime + (($maxexectime < 20) ? $maxexectime : ($maxexectime - 20)), 736 'idlist' => $idlist, 737 ); 738 $status = migrate_content_process_all($messages, $options); 739 foreach ($messages as $message) { 740 if (!isset($context['sandbox']['message'])) { 741 $context['sandbox']['message'] = $message . '<br />'; 742 } 743 else { 744 $context['sandbox']['message'] .= $message . '<br />'; 745 } 746 $context['message'] = $context['sandbox']['message']; 747 $context['results'][] = $message; 748 $context['sandbox']['numopsdone'] += $options['opcount']; 749 } 750 751 // If we did not arrive via a timeout, we must have finished all operations 752 if ($status != MIGRATE_STATUS_TIMEDOUT) { 753 $context['finished'] = 1; 754 } 755 else { 756 // Not done, report what percentage done we are (in terms of number of operations) 757 $context['finished'] = $context['sandbox']['numopsdone']/$context['sandbox']['numops']; 758 } 759 760 // If requested save timers for eventual display 761 if (variable_get('migrate_display_timers', 0)) { 762 global $timers; 763 foreach ($timers as $name => $timerec) { 764 if (isset($timerec['time'])) { 765 $context['sandbox']['times'][$name] += $timerec['time']/1000; 766 } 767 } 768 // When all done, display the timers 769 if ($context['finished'] == 1 && isset($context['sandbox']['times'])) { 770 global $timers; 771 arsort($context['sandbox']['times']); 772 foreach ($context['sandbox']['times'] as $name => $total) { 773 drupal_set_message("$name: " . round($total, 2)); 774 } 775 } 776 } 777 } 778 779 /** 780 * Process all enabled migration processes 781 * 782 * @param $messages 783 * Array of messages to (ultimately) be displayed by the caller. 784 * @param $options 785 * Keyed array of optional options: 786 * itemlimit - Maximum number of items to process 787 * timelimit - Unix timestamp after which to stop processing 788 * idlist - Comma-separated list of source IDs to process, instead of proceeding through 789 * all unmigrated rows 790 * opcount - Number of clearing or import operations performed 791 * feedback - Keyed array controlling status feedback to the caller 792 * function - PHP function to call, passing a message to be displayed 793 * frequency - How often to call the function 794 * frequency_unit - How to interpret frequency (items or seconds) 795 * 796 * @return 797 * Status of the migration process: 798 */ 799 function migrate_content_process_all(&$messages = array(), &$options = array()) { 800 // First, perform any clearing actions in reverse order 801 $result = db_query("SELECT mcsid 802 FROM {migrate_content_sets} 803 WHERE clearing=1 804 ORDER BY weight DESC"); 805 $context['sandbox']['timedout'] = FALSE; 806 if (!isset($options['opcount'])) { 807 $options['opcount'] = 0; 808 } 809 while ($row = db_fetch_object($result)) { 810 $status = migrate_content_process_clear($row->mcsid, $messages, $options); 811 if ($status != MIGRATE_STATUS_SUCCESS) { 812 break; 813 } 814 $options['opcount']++; 815 } 816 817 // Then, any import actions going forward 818 $result = db_query("SELECT mcsid 819 FROM {migrate_content_sets} 820 WHERE importing=1 OR scanning=1 821 ORDER BY weight"); 822 while ($row = db_fetch_object($result)) { 823 $status = migrate_content_process_import($row->mcsid, $messages, $options); 824 if ($status != MIGRATE_STATUS_SUCCESS) { 825 break; 826 } 827 $options['opcount']++; 828 } 829 830 return $status; 831 } 832 833 function _migrate_progress_message($starttime, $numitems, $description, $import = TRUE, $status = MIGRATE_STATUS_SUCCESS) { 834 $time = (microtime(TRUE) - $starttime); 835 if ($time > 0) { 836 $perminute = round(60*$numitems/$time); 837 $time = round($time, 1); 838 } 839 else { 840 $perminute = '?'; 841 } 842 843 if ($import) { 844 switch ($status) { 845 case MIGRATE_STATUS_SUCCESS: 846 $basetext = "!numitems items imported in !time seconds (!perminute/min) - done importing '!description'";; 847 break; 848 case MIGRATE_STATUS_FAILURE: 849 $basetext = "!numitems items imported in !time seconds (!perminute/min) - failure importing '!description'";; 850 break; 851 case MIGRATE_STATUS_TIMEDOUT: 852 case MIGRATE_STATUS_IN_PROGRESS: 853 $basetext = "!numitems items imported in !time seconds (!perminute/min) - continuing importing '!description'"; 854 break; 855 case MIGRATE_STATUS_CANCELLED: 856 $basetext = "!numitems items imported in !time seconds (!perminute/min) - cancelled importing '!description'"; 857 break; 858 } 859 } 860 else { 861 switch ($status) { 862 case MIGRATE_STATUS_SUCCESS: 863 $basetext = "!numitems previously-imported items deleted in !time seconds (!perminute/min) - done clearing '!description'";; 864 break; 865 case MIGRATE_STATUS_FAILURE: 866 $basetext = "!numitems previously-imported items deleted in !time seconds (!perminute/min) - failure clearing '!description'";; 867 break; 868 case MIGRATE_STATUS_TIMEDOUT: 869 case MIGRATE_STATUS_IN_PROGRESS: 870 $basetext = "!numitems previously-imported items deleted in !time seconds (!perminute/min) - continuing clearing '!description'"; 871 break; 872 case MIGRATE_STATUS_CANCELLED: 873 $basetext = "!numitems previously-imported items deleted in !time seconds (!perminute/min) - cancelled clearing '!description'"; 874 break; 875 } 876 } 877 $message = t($basetext, 878 array('!numitems' => $numitems, '!time' => $time, '!perminute' => $perminute, 879 '!description' => $description)); 880 881 return $message; 882 } 883 884 /* 885 * Implementation of hook_init(). 886 */ 887 function migrate_init() { 888 // Loads the hooks for the supported modules. 889 // TODO: Be more lazy - only load when really needed 890 $path = drupal_get_path('module', 'migrate') .'/modules'; 891 $files = drupal_system_listing('.*\.inc$', $path, 'name', 0); 892 foreach ($files as $module_name => $file) { 893 if (module_exists($module_name)) { 894 include_once($file->filename); 895 } 896 } 897 898 // Add main CSS functionality. 899 drupal_add_css(drupal_get_path('module', 'migrate') .'/migrate.css'); 900 } 901 902 /** 903 * Implementation of hook_action_info(). 904 */ 905 /* Revisit 906 function migrate_action_info() { 907 $info['migrate_content_process_clear'] = array( 908 'type' => 'migrate', 909 'description' => t('Clear a migration content set'), 910 'configurable' => FALSE, 911 'hooks' => array( 912 'cron' => array('run'), 913 ), 914 ); 915 $info['migrate_content_process_import'] = array( 916 'type' => 'migrate', 917 'description' => t('Import a migration content set'), 918 'configurable' => FALSE, 919 'hooks' => array( 920 'cron' => array('run'), 921 ), 922 ); 923 $info['migrate_content_process_all_action'] = array( 924 'type' => 'migrate', 925 'description' => t('Perform all active migration processes'), 926 'configurable' => FALSE, 927 'hooks' => array( 928 'cron' => array('run'), 929 ), 930 ); 931 return $info; 932 } 933 */ 934 935 /** 936 * Implementation of hook_cron(). 937 */ 938 function migrate_cron() { 939 if (variable_get('migrate_enable_cron', 0)) { 940 $path = drupal_get_path('module', 'migrate') . '/migrate_pages.inc'; 941 include_once($path); 942 // Elevate privileges so node deletion/creation works in cron 943 session_save_session(FALSE); 944 global $user; 945 $saveuser = $user; 946 $user = user_load(array('uid' => 1)); 947 $messages = array(); 948 // A zero max_execution_time means no limit - but let's set a reasonable 949 // limit anyway 950 $starttime = variable_get('cron_semaphore', 0); 951 $maxexectime = ini_get('max_execution_time'); 952 if (!$maxexectime) { 953 $maxexectime = 240; 954 } 955 $options = array('timelimit' => $starttime + (($maxexectime < 20) ? $maxexectime : ($maxexectime - 20))); 956 migrate_content_process_all($messages, $options); 957 $user = $saveuser; 958 session_save_session(TRUE); 959 } 960 } 961 962 /** 963 * Implementation of hook_perm(). 964 */ 965 function migrate_perm() { 966 return array(MIGRATE_ACCESS_BASIC, MIGRATE_ACCESS_ADVANCED); 967 } 968 969 /** 970 * Implementation of hook_help(). 971 */ 972 function migrate_help($page, $arg) { 973 switch ($page) { 974 case 'admin/content/migrate': 975 return theme('advanced_help_topic', 'migrate', 'about', 'icon') . 976 t('Click the question marks like this one to read the migrate module help topics.'); 977 case 'admin/content/migrate/content_sets': 978 return t('Define sets of mappings from imported tables to Drupal content. These are the 979 migrations which are later processed.'); 980 case 'admin/content/migrate/process': 981 return t('View and manage import processes here. Processes that are enabled for processing 982 are checked - they can be cancelled by unchecking, or new processes begun by checking, 983 then clicking Submit. Any checked process will run in the background (via cron) 984 automatically - you may also run them interactively or in drush. A process that is 985 actively running will be <span class="migrate-running">highlighted</span>.'); 986 case 'admin/content/migrate/tools': 987 return t('Besides content that is migrated into a new site, nodes may be manually 988 created during the testing process. Typically you will want to clear these before the 989 final migration - if you are <strong>absolutely positive</strong> that all nodes of a 990 given type should be deleted, you may do so here.'); 991 } 992 } 993 994 /** 995 * Implementation of hook_menu(). 996 */ 997 function migrate_menu() { 998 $items = array(); 999 1000 $items['admin/content/migrate'] = array( 1001 'title' => 'Migrate', 1002 'description' => 'Manage data migration from external sources', 1003 'page callback' => 'migrate_front', 1004 'access arguments' => array(MIGRATE_ACCESS_BASIC), 1005 'file' => 'migrate_pages.inc', 1006 ); 1007 $items['admin/content/migrate/content_sets'] = array( 1008 'title' => 'Content sets', 1009 'description' => 'Manage content sets: mappings of source data to Drupal content', 1010 'weight' => 2, 1011 'page callback' => 'migrate_content_sets', 1012 'access arguments' => array(MIGRATE_ACCESS_ADVANCED), 1013 'file' => 'migrate_pages.inc', 1014 ); 1015 $items['admin/content/migrate/process'] = array( 1016 'title' => 'Process', 1017 'description' => 'Perform and monitor the creation of Drupal content from source data', 1018 'weight' => 3, 1019 'page callback' => 'migrate_dashboard', 1020 'access arguments' => array(MIGRATE_ACCESS_BASIC), 1021 'file' => 'migrate_pages.inc', 1022 ); 1023 $items['admin/content/migrate/tools'] = array( 1024 'title' => 'Tools', 1025 'description' => 'Additional tools for managing migration', 1026 'weight' => 4, 1027 'page callback' => 'migrate_tools', 1028 'access arguments' => array(MIGRATE_ACCESS_ADVANCED), 1029 'file' => 'migrate_pages.inc', 1030 ); 1031 $items['admin/content/migrate/settings'] = array( 1032 'title' => 'Settings', 1033 'description' => 'Migrate module settings', 1034 'weight' => 5, 1035 'page callback' => 'migrate_settings', 1036 'access arguments' => array(MIGRATE_ACCESS_ADVANCED), 1037 'file' => 'migrate_pages.inc', 1038 ); 1039 $items['admin/content/migrate/content_sets/%'] = array( 1040 'title' => 'Content set', 1041 'page callback' => 'drupal_get_form', 1042 'page arguments' => array('migrate_content_set_mappings', 4), 1043 'access arguments' => array(MIGRATE_ACCESS_ADVANCED), 1044 'type' => MENU_CALLBACK, 1045 'file' => 'migrate_pages.inc', 1046 ); 1047 $items['migrate/xlat/%'] = array( 1048 'page callback' => 'migrate_xlat', 1049 'access arguments' => array('access content'), 1050 'page arguments' => array(2), 1051 'type' => MENU_CALLBACK, 1052 ); 1053 return $items; 1054 } 1055 1056 /** 1057 * Implementation of hook_schema_alter(). 1058 * @param $schema 1059 */ 1060 function migrate_schema_alter(&$schema) { 1061 // Check for table existence - at install time, hook_schema_alter() may be called 1062 // before our install hook. 1063 if (db_table_exists('migrate_content_sets')) { 1064 $result = db_query("SELECT * FROM {migrate_content_sets}"); 1065 while ($content_set = db_fetch_object($result)) { 1066 $maptablename = migrate_map_table_name($content_set->mcsid); 1067 $msgtablename = migrate_message_table_name($content_set->mcsid); 1068 1069 // Get the proper field definition for the sourcekey 1070 $view = views_get_view($content_set->view_name); 1071 if (!$view) { 1072 drupal_set_message(t('View !view does not exist - either (re)create this view, or 1073 remove the migrate content set using it.', array('!view' => $content_set->view_name))); 1074 continue; 1075 } 1076 // Must do this to load the database 1077 $view->init_query(); 1078 1079 // TODO: For now, PK must be in base_table 1080 if (isset($view->base_database)) { 1081 $tabledb = $view->base_database; 1082 } 1083 else { 1084 $tabledb = 'default'; 1085 } 1086 $tablename = $view->base_table; 1087 db_set_active($tabledb); 1088 $inspect = schema_invoke('inspect'); 1089 db_set_active('default'); 1090 $sourceschema = $inspect[$tablename]; 1091 // If the PK of the content set is defined, make sure we have a mapping table 1092 if ($sourcekey = $content_set->sourcekey) { 1093 $sourcefield = $sourceschema['fields'][$sourcekey]; 1094 if (!$sourcefield) { 1095 // strip base table name if views prepended it 1096 $baselen = drupal_strlen($tablename); 1097 if (!strncasecmp($sourcekey, $tablename . '_', $baselen + 1)) { 1098 $sourcekey = drupal_substr($sourcekey, $baselen + 1); 1099 } 1100 $sourcefield = $sourceschema['fields'][$sourcekey]; 1101 } 1102 // We don't want serial fields to behave serially, so change to int 1103 if ($sourcefield['type'] == 'serial') { 1104 $sourcefield['type'] = 'int'; 1105 } 1106 $schema[$maptablename] = _migrate_map_table_schema($sourcefield); 1107 $schema[$msgtablename] = _migrate_message_table_schema($sourcefield); 1108 } 1109 } 1110 } 1111 } 1112 1113 /* 1114 * Translate URIs from an old site to the new one 1115 * Requires adding RewriteRules to .htaccess. For example, if the URLs 1116 * for news articles had the form 1117 * http://example.com/issues/news/[OldID].html, use this rule: 1118 * 1119 * RewriteRule ^issues/news/([0-9]+).html$ /migrate/xlat/node/$1 [L] 1120 */ 1121 function migrate_xlat($contenttype, $oldid) { 1122 $uri = ''; 1123 if ($contenttype && $oldid) { 1124 $newid = _migrate_xlat_get_new_id($contenttype, $oldid); 1125 if ($newid) { 1126 $uri = migrate_invoke_all("xlat_$contenttype", $newid); 1127 drupal_goto($uri[0], NULL, NULL, 301); 1128 } 1129 } 1130 } 1131 1132 /* 1133 * Helper function to translate an ID from a source file to the corresponding 1134 * Drupal-side ID (nid, uid, etc.) 1135 * Note that the result may be ambiguous - for example, if you are importing 1136 * nodes from different content sets, they might have overlapping source IDs. 1137 */ 1138 function _migrate_xlat_get_new_id($contenttype, $oldid) { 1139 $result = db_query("SELECT mcsid 1140 FROM {migrate_content_sets} 1141 WHERE contenttype='%s'", 1142 $contenttype); 1143 while ($row = db_fetch_object($result)) { 1144 static $maptables = array(); 1145 if (!isset($maptables[$row->mcsid])) { 1146 $maptables[$row->mcsid] = migrate_map_table_name($row->mcsid); 1147 } 1148 $sql = "SELECT destid 1149 FROM {" . $maptables[$row->mcsid] . "} 1150 WHERE sourceid='%s'"; 1151 $id = db_result(db_query($sql, $oldid)); 1152 if ($id) { 1153 return $id; 1154 } 1155 } 1156 return NULL; 1157 } 1158 1159 /** 1160 * Implementation of hook_theme(). 1161 * 1162 * Registers all theme functions used in this module. 1163 */ 1164 function migrate_theme() { 1165 return array( 1166 'migrate_mapping_table' => array('arguments' => array('form')), 1167 '_migrate_dashboard_form' => array( 1168 'arguments' => array('form' => NULL), 1169 'function' => 'theme_migrate_dashboard', 1170 ), 1171 '_migrate_tools_form' => array( 1172 'arguments' => array('form' => NULL), 1173 'function' => 'theme_migrate_tools', 1174 ), 1175 '_migrate_settings_form' => array( 1176 'arguments' => array('form' => NULL), 1177 'function' => 'theme_migrate_settings', 1178 ), 1179 'migrate_content_set_mappings' => array( 1180 'arguments' => array('form' => NULL), 1181 'function' => 'theme_migrate_content_set_mappings', 1182 ), 1183 ); 1184 } 1185 1186 function migrate_map_table_name($mcsid) { 1187 return "migrate_map_$mcsid"; 1188 } 1189 1190 function migrate_message_table_name($mcsid) { 1191 return "migrate_msgs_$mcsid"; 1192 } 1193 1194 function _migrate_map_table_name($mcsid) { 1195 return migrate_map_table_name($mcsid); 1196 } 1197 1198 function _migrate_message_table_name($mcsid) { 1199 return migrate_message_table_name($mcsid); 1200 } 1201 1202 function _migrate_map_table_schema($sourcefield) { 1203 $schema = array( 1204 'description' => t('Mappings from source key to destination key'), 1205 'fields' => array( 1206 'sourceid' => $sourcefield, 1207 // @TODO: Assumes destination key is unsigned int 1208 'destid' => array( 1209 'type' => 'int', 1210 'unsigned' => TRUE, 1211 'not null' => TRUE, 1212 ), 1213 ), 1214 'primary key' => array('sourceid'), 1215 'indexes' => array( 1216 'idkey' => array('destid'), 1217 ), 1218 ); 1219 return $schema; 1220 } 1221 1222 function _migrate_message_table_schema($sourcefield) { 1223 $schema = array( 1224 'description' => t('Import errors'), 1225 'fields' => array( 1226 'mceid' => array( 1227 'type' => 'serial', 1228 'unsigned' => TRUE, 1229 'not null' => TRUE, 1230 ), 1231 'sourceid' => $sourcefield, 1232 'level' => array( 1233 'type' => 'int', 1234 'unsigned' => TRUE, 1235 'not null' => TRUE, 1236 'default' => 1, 1237 ), 1238 'message' => array( 1239 'type' => 'text', 1240 'size' => 'medium', 1241 'not null' => TRUE, 1242 ), 1243 ), 1244 'primary key' => array('mceid'), 1245 'indexes' => array( 1246 'sourceid' => array('sourceid'), 1247 ), 1248 ); 1249 return $schema; 1250 } 1251 1252 function migrate_views_api() { 1253 return array('api' => '2.0'); 1254 } 1255 1256 /** 1257 * Check to see if the advanced help module is installed, and if not put up 1258 * a message. 1259 * 1260 * Only call this function if the user is already in a position for this to 1261 * be useful. 1262 */ 1263 function migrate_check_advanced_help() { 1264 if (variable_get('migrate_hide_help_message', FALSE)) { 1265 return; 1266 } 1267 1268 if (!module_exists('advanced_help')) { 1269 $filename = db_result(db_query("SELECT filename FROM {system} WHERE type = 'module' AND name = 'advanced_help'")); 1270 if ($filename && file_exists($filename)) { 1271 drupal_set_message(t('If you <a href="@modules">enable the advanced help module</a>, 1272 Migrate will provide more and better help. <a href="@hide">Hide this message.</a>', 1273 array('@modules' => url('admin/build/modules'), 1274 '@hide' => url('admin/build/views/tools')))); 1275 } 1276 else { 1277 drupal_set_message(t('If you install the advanced help module from !href, Migrate will provide more and better help. <a href="@hide">Hide this message.</a>', array('!href' => l('http://drupal.org/project/advanced_help', 'http://drupal.org/project/advanced_help'), '@hide' => url('admin/content/migrate/settings')))); 1278 } 1279 } 1280 } 1281 1282 /** 1283 * Check if a date is valid and return the correct 1284 * timestamp to use. Returns -1 if the date is not 1285 * considered valid. 1286 */ 1287 function _migrate_valid_date($date) { 1288 //TODO: really check whether the date is valid!! 1289 if (empty($date)) { 1290 return -1; 1291 } 1292 1293 if (is_numeric($date) && $date > -1) { 1294 return $date; 1295 } 1296 // strtotime() doesn't recognize dashes as separators, change to slashes 1297 $date = str_replace('-', '/', $date); 1298 1299 $time = strtotime($date); 1300 if ($time < 0 || !$time) { 1301 // Handles form YYYY-MM-DD HH:MM:SS.garbage 1302 if (drupal_strlen($date) > 19) { 1303 $time = strtotime(drupal_substr($date, 0, 19)); 1304 if ($time < 0 || !$time) { 1305 return -1; 1306 } 1307 } 1308 } 1309 return $time; 1310 } 1311
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated: Mon Jul 9 18:01:44 2012 | Cross-referenced by PHPXref 0.7 |