MDL-27448 Improved resource module converter
[moodle.git] / backup / converter / moodle1 / lib.php
CommitLineData
1e2c7351
DM
1<?php
2
3// This file is part of Moodle - http://moodle.org/
4//
5// Moodle is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// Moodle is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17
18/**
19 * Provides classes used by the moodle1 converter
20 *
21 * @package backup-convert
22 * @subpackage moodle1
23 * @copyright 2011 Mark Nielsen <mark@moodlerooms.com>
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 */
26
27defined('MOODLE_INTERNAL') || die();
28
29require_once($CFG->dirroot . '/backup/converter/convertlib.php');
30require_once($CFG->dirroot . '/backup/util/xml/parser/progressive_parser.class.php');
31require_once($CFG->dirroot . '/backup/util/xml/parser/processors/grouped_parser_processor.class.php');
a5fe5912
DM
32require_once($CFG->dirroot . '/backup/util/dbops/backup_dbops.class.php');
33require_once($CFG->dirroot . '/backup/util/dbops/backup_controller_dbops.class.php');
34require_once($CFG->dirroot . '/backup/util/dbops/restore_dbops.class.php');
96f7c7ad 35require_once($CFG->dirroot . '/backup/util/xml/contenttransformer/xml_contenttransformer.class.php');
1e2c7351
DM
36require_once(dirname(__FILE__) . '/handlerlib.php');
37
38/**
39 * Converter of Moodle 1.9 backup into Moodle 2.x format
40 */
41class moodle1_converter extends base_converter {
42
43 /** @var progressive_parser moodle.xml file parser */
44 protected $xmlparser;
45
46 /** @var moodle1_parser_processor */
47 protected $xmlprocessor;
48
49 /** @var array of {@link convert_path} to process */
50 protected $pathelements = array();
51
6cfa5a32
DM
52 /** @var null|string the current module being processed - used to expand the MOD paths */
53 protected $currentmod = null;
1e2c7351 54
6cfa5a32
DM
55 /** @var null|string the current block being processed - used to expand the BLOCK paths */
56 protected $currentblock = null;
1e2c7351
DM
57
58 /** @var string path currently locking processing of children */
59 protected $pathlock;
60
23007e5d
DM
61 /** @var int used by the serial number {@link get_nextid()} */
62 private $nextid = 1;
63
1e2c7351
DM
64 /**
65 * Instructs the dispatcher to ignore all children below path processor returning it
66 */
67 const SKIP_ALL_CHILDREN = -991399;
68
69 /**
70 * Detects the Moodle 1.9 format of the backup directory
71 *
72 * @param string $tempdir the name of the backup directory
73 * @return null|string backup::FORMAT_MOODLE1 if the Moodle 1.9 is detected, null otherwise
74 */
75 public static function detect_format($tempdir) {
76 global $CFG;
77
78 $filepath = $CFG->dataroot . '/temp/backup/' . $tempdir . '/moodle.xml';
79 if (file_exists($filepath)) {
80 // looks promising, lets load some information
81 $handle = fopen($filepath, 'r');
82 $first_chars = fread($handle, 200);
83 fclose($handle);
84
85 // check if it has the required strings
86 if (strpos($first_chars,'<?xml version="1.0" encoding="UTF-8"?>') !== false and
87 strpos($first_chars,'<MOODLE_BACKUP>') !== false and
88 strpos($first_chars,'<INFO>') !== false) {
89
90 return backup::FORMAT_MOODLE1;
91 }
92 }
93
94 return null;
95 }
96
97 /**
98 * Initialize the instance if needed, called by the constructor
99 *
100 * Here we create objects we need before the execution.
101 */
102 protected function init() {
103
104 // ask your mother first before going out playing with toys
105 parent::init();
106
107 // good boy, prepare XML parser and processor
108 $this->xmlparser = new progressive_parser();
109 $this->xmlparser->set_file($this->get_tempdir_path() . '/moodle.xml');
110 $this->xmlprocessor = new moodle1_parser_processor($this);
111 $this->xmlparser->set_processor($this->xmlprocessor);
112
113 // make sure that MOD and BLOCK paths are visited
114 $this->xmlprocessor->add_path('/MOODLE_BACKUP/COURSE/MODULES/MOD');
115 $this->xmlprocessor->add_path('/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK');
116
117 // register the conversion handlers
118 foreach (moodle1_handlers_factory::get_handlers($this) as $handler) {
1e2c7351
DM
119 $this->register_handler($handler, $handler->get_paths());
120 }
121 }
122
123 /**
124 * Converts the contents of the tempdir into the target format in the workdir
125 */
126 protected function execute() {
9b5f1ad5 127 $this->create_stash_storage();
1e2c7351 128 $this->xmlparser->process();
9b5f1ad5 129 $this->drop_stash_storage();
1e2c7351
DM
130 }
131
132 /**
133 * Register a handler for the given path elements
134 */
135 protected function register_handler(moodle1_handler $handler, array $elements) {
136
137 // first iteration, push them to new array, indexed by name
138 // to detect duplicates in names or paths
139 $names = array();
140 $paths = array();
141 foreach($elements as $element) {
142 if (!$element instanceof convert_path) {
143 throw new convert_exception('path_element_wrong_class', get_class($element));
144 }
145 if (array_key_exists($element->get_name(), $names)) {
146 throw new convert_exception('path_element_name_alreadyexists', $element->get_name());
147 }
148 if (array_key_exists($element->get_path(), $paths)) {
149 throw new convert_exception('path_element_path_alreadyexists', $element->get_path());
150 }
151 $names[$element->get_name()] = true;
152 $paths[$element->get_path()] = $element;
153 }
154
155 // now, for each element not having a processing object yet, assign the handler
156 // if the element is not a memeber of a group
157 foreach($paths as $key => $element) {
158 if (is_null($element->get_processing_object()) and !$this->grouped_parent_exists($element, $paths)) {
159 $paths[$key]->set_processing_object($handler);
160 }
161 // add the element path to the processor
162 $this->xmlprocessor->add_path($element->get_path(), $element->is_grouped());
163 }
164
165 // done, store the paths (duplicates by path are discarded)
166 $this->pathelements = array_merge($this->pathelements, $paths);
167
168 // remove the injected plugin name element from the MOD and BLOCK paths
169 // and register such collapsed path, too
170 foreach ($elements as $element) {
171 $path = $element->get_path();
172 $path = preg_replace('/^\/MOODLE_BACKUP\/COURSE\/MODULES\/MOD\/(\w+)\//', '/MOODLE_BACKUP/COURSE/MODULES/MOD/', $path);
173 $path = preg_replace('/^\/MOODLE_BACKUP\/COURSE\/BLOCKS\/BLOCK\/(\w+)\//', '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK/', $path);
174 if (!empty($path) and $path != $element->get_path()) {
175 $this->xmlprocessor->add_path($path, false);
176 }
177 }
178 }
179
180 /**
181 * Helper method used by {@link self::register_handler()}
182 *
183 * @param convert_path $pelement path element
184 * @param array of convert_path instances
185 * @return bool true if grouped parent was found, false otherwise
186 */
187 protected function grouped_parent_exists($pelement, $elements) {
188
189 foreach ($elements as $element) {
190 if ($pelement->get_path() == $element->get_path()) {
191 // don't compare against itself
192 continue;
193 }
194 // if the element is grouped and it is a parent of pelement, return true
195 if ($element->is_grouped() and strpos($pelement->get_path() . '/', $element->get_path()) === 0) {
196 return true;
197 }
198 }
199
200 // no grouped parent found
201 return false;
202 }
203
204 /**
205 * Process the data obtained from the XML parser processor
206 *
207 * This methods receives one chunk of information from the XML parser
208 * processor and dispatches it, following the naming rules.
209 * We are expanding the modules and blocks paths here to include the plugin's name.
210 *
211 * @param array $data
212 */
213 public function process_chunk($data) {
214
215 $path = $data['path'];
216
217 // expand the MOD paths so that they contain the module name
218 if ($path === '/MOODLE_BACKUP/COURSE/MODULES/MOD') {
219 $this->currentmod = strtoupper($data['tags']['MODTYPE']);
220 $path = '/MOODLE_BACKUP/COURSE/MODULES/MOD/' . $this->currentmod;
221
222 } else if (strpos($path, '/MOODLE_BACKUP/COURSE/MODULES/MOD') === 0) {
223 $path = str_replace('/MOODLE_BACKUP/COURSE/MODULES/MOD', '/MOODLE_BACKUP/COURSE/MODULES/MOD/' . $this->currentmod, $path);
224 }
225
226 // expand the BLOCK paths so that they contain the module name
227 if ($path === '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK') {
228 $this->currentblock = strtoupper($data['tags']['NAME']);
229 $path = '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK/' . $this->currentblock;
230
231 } else if (strpos($path, '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK') === 0) {
232 $path = str_replace('/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK', '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK/' . $this->currentmod, $path);
233 }
234
235 if ($path !== $data['path']) {
236 if (!array_key_exists($path, $this->pathelements)) {
237 // no handler registered for the transformed MOD or BLOCK path
238 // todo add this event to the convert log instead of debugging
239 //debugging('No handler registered for the path ' . $path);
240 return;
241
242 } else {
243 // pretend as if the original $data contained the tranformed path
244 $data['path'] = $path;
245 }
246 }
247
248 if (!array_key_exists($data['path'], $this->pathelements)) {
249 // path added to the processor without the handler
250 throw new convert_exception('missing_path_handler', $data['path']);
251 }
252
beb7de37
DM
253 $element = $this->pathelements[$data['path']];
254 $object = $element->get_processing_object();
255 $method = $element->get_processing_method();
256 $returned = null; // data returned by the processing method, if any
1e2c7351
DM
257
258 if (empty($object)) {
259 throw new convert_exception('missing_processing_object', $object);
260 }
261
262 // release the lock if we aren't anymore within children of it
263 if (!is_null($this->pathlock) and strpos($data['path'], $this->pathlock) === false) {
264 $this->pathlock = null;
265 }
266
267 // if the path is not locked, apply the element's recipes and dispatch
268 // the cooked tags to the processing method
269 if (is_null($this->pathlock)) {
beb7de37
DM
270 $rawdatatags = $data['tags'];
271 $data['tags'] = $element->apply_recipes($data['tags']);
46ff8b0e
DM
272
273 // if the processing method exists, give it a chance to modify data
274 if (method_exists($object, $method)) {
275 $returned = $object->$method($data['tags'], $rawdatatags);
276 }
1e2c7351
DM
277 }
278
279 // if the dispatched method returned SKIP_ALL_CHILDREN, remember the current path
280 // and lock it so that its children are not dispatched
beb7de37 281 if ($returned === self::SKIP_ALL_CHILDREN) {
1e2c7351
DM
282 // check we haven't any previous lock
283 if (!is_null($this->pathlock)) {
284 throw new convert_exception('already_locked_path', $data['path']);
285 }
286 // set the lock - nothing below the current path will be dispatched
287 $this->pathlock = $data['path'] . '/';
288
289 // if the method has returned any info, set element data to it
beb7de37
DM
290 } else if (!is_null($returned)) {
291 $element->set_data($returned);
1e2c7351
DM
292
293 // use just the cooked parsed data otherwise
294 } else {
295 $element->set_data($data);
296 }
297 }
298
299 /**
300 * Executes operations required at the start of a watched path
301 *
6cfa5a32
DM
302 * For MOD and BLOCK paths, this is supported only for the sub-paths, not the root
303 * module/block element. For the illustration:
304 *
305 * You CAN'T attach on_xxx_start() listener to a path like
306 * /MOODLE_BACKUP/COURSE/MODULES/MOD/WORKSHOP because the <MOD> must
307 * be processed first in {@link self::process_chunk()} where $this->currentmod
308 * is set.
309 *
310 * You CAN attach some on_xxx_start() listener to a path like
311 * /MOODLE_BACKUP/COURSE/MODULES/MOD/WORKSHOP/SUBMISSIONS because it is
312 * a sub-path under <MOD> and we have $this->currentmod already set when the
313 * <SUBMISSIONS> is reached.
1e2c7351 314 *
1e2c7351
DM
315 * @param string $path in the original file
316 */
317 public function path_start_reached($path) {
a5fe5912 318
6cfa5a32
DM
319 if ($path === '/MOODLE_BACKUP/COURSE/MODULES/MOD') {
320 $this->currentmod = null;
321 $forbidden = true;
322
323 } else if (strpos($path, '/MOODLE_BACKUP/COURSE/MODULES/MOD') === 0) {
324 // expand the MOD paths so that they contain the module name
325 $path = str_replace('/MOODLE_BACKUP/COURSE/MODULES/MOD', '/MOODLE_BACKUP/COURSE/MODULES/MOD/' . $this->currentmod, $path);
326 }
327
328 if ($path === '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK') {
329 $this->currentmod = null;
330 $forbidden = true;
331
332 } else if (strpos($path, '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK') === 0) {
333 // expand the BLOCK paths so that they contain the module name
334 $path = str_replace('/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK', '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK/' . $this->currentmod, $path);
335 }
336
a5fe5912
DM
337 if (empty($this->pathelements[$path])) {
338 return;
339 }
340
341 $element = $this->pathelements[$path];
342 $pobject = $element->get_processing_object();
46ff8b0e 343 $method = $element->get_start_method();
a5fe5912
DM
344
345 if (method_exists($pobject, $method)) {
6cfa5a32
DM
346 if (empty($forbidden)) {
347 $pobject->$method();
348
349 } else {
350 // this path is not supported because we do not know the module/block yet
351 throw new coding_exception('Attaching the on-start event listener to the root MOD or BLOCK element is forbidden.');
352 }
a5fe5912 353 }
1e2c7351
DM
354 }
355
356 /**
357 * Executes operations required at the end of a watched path
358 *
1e2c7351
DM
359 * @param string $path in the original file
360 */
361 public function path_end_reached($path) {
a5fe5912
DM
362
363 // expand the MOD paths so that they contain the current module name
364 if ($path === '/MOODLE_BACKUP/COURSE/MODULES/MOD') {
365 $path = '/MOODLE_BACKUP/COURSE/MODULES/MOD/' . $this->currentmod;
366
367 } else if (strpos($path, '/MOODLE_BACKUP/COURSE/MODULES/MOD') === 0) {
368 $path = str_replace('/MOODLE_BACKUP/COURSE/MODULES/MOD', '/MOODLE_BACKUP/COURSE/MODULES/MOD/' . $this->currentmod, $path);
369 }
370
371 // expand the BLOCK paths so that they contain the module name
372 if ($path === '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK') {
373 $path = '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK/' . $this->currentblock;
374
375 } else if (strpos($path, '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK') === 0) {
376 $path = str_replace('/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK', '/MOODLE_BACKUP/COURSE/BLOCKS/BLOCK/' . $this->currentmod, $path);
377 }
378
379 if (empty($this->pathelements[$path])) {
380 return;
381 }
382
383 $element = $this->pathelements[$path];
384 $pobject = $element->get_processing_object();
46ff8b0e 385 $method = $element->get_end_method();
1cc0e42a 386 $data = $element->get_data();
a5fe5912
DM
387
388 if (method_exists($pobject, $method)) {
1cc0e42a 389 $pobject->$method($data['tags']);
a5fe5912
DM
390 }
391 }
392
393 /**
9b5f1ad5 394 * Creates the temporary storage for stashed data
a5fe5912 395 *
9b5f1ad5 396 * This implementation uses backup_ids_temp table.
a5fe5912 397 */
9b5f1ad5
DM
398 public function create_stash_storage() {
399 backup_controller_dbops::create_backup_ids_temp_table($this->get_id());
a5fe5912
DM
400 }
401
402 /**
9b5f1ad5 403 * Drops the temporary storage of stashed data
a5fe5912 404 *
9b5f1ad5 405 * This implementation uses backup_ids_temp table.
a5fe5912 406 */
9b5f1ad5
DM
407 public function drop_stash_storage() {
408 backup_controller_dbops::drop_backup_ids_temp_table($this->get_id());
1e2c7351 409 }
a5fe5912 410
beb7de37 411 /**
9b5f1ad5 412 * Stores some information for later processing
beb7de37 413 *
9b5f1ad5
DM
414 * This implementation uses backup_ids_temp table to store data. Make
415 * sure that the $stashname + $itemid combo is unique.
beb7de37
DM
416 *
417 * @param string $stashname name of the stash
418 * @param mixed $info information to stash
9b5f1ad5 419 * @param int $itemid optional id for multiple infos within the same stashname
beb7de37 420 */
9b5f1ad5
DM
421 public function set_stash($stashname, $info, $itemid = 0) {
422 try {
423 restore_dbops::set_backup_ids_record($this->get_id(), $stashname, $itemid, 0, null, $info);
424
425 } catch (dml_exception $e) {
426 throw new moodle1_convert_storage_exception('unable_to_restore_stash', null, $e->getMessage());
427 }
beb7de37
DM
428 }
429
430 /**
431 * Restores a given stash stored previously by {@link self::set_stash()}
432 *
433 * @param string $stashname name of the stash
9b5f1ad5
DM
434 * @param int $itemid optional id for multiple infos within the same stashname
435 * @throws moodle1_convert_empty_storage_exception if the info has not been stashed previously
beb7de37
DM
436 * @return mixed stashed data
437 */
9b5f1ad5
DM
438 public function get_stash($stashname, $itemid = 0) {
439
440 $record = restore_dbops::get_backup_ids_record($this->get_id(), $stashname, $itemid);
441
442 if (empty($record)) {
6357693c 443 throw new moodle1_convert_empty_storage_exception('required_not_stashed_data', array($stashname, $itemid));
9b5f1ad5
DM
444 } else {
445 return $record->info;
446 }
beb7de37
DM
447 }
448
cd92d83b
DM
449 /**
450 * Returns the list of existing stashes
451 *
452 * @return array
453 */
454 public function get_stash_names() {
455 global $DB;
456
457 $search = array(
458 'backupid' => $this->get_id(),
459 );
460
461 return array_keys($DB->get_records('backup_ids_temp', $search, '', 'itemname'));
462 }
463
6d73f185
DM
464 /**
465 * Returns the list of stashed $itemids in the given stash
466 *
467 * @param string $stashname
468 * @return array
469 */
470 public function get_stash_itemids($stashname) {
471 global $DB;
472
473 $search = array(
474 'backupid' => $this->get_id(),
475 'itemname' => $stashname
476 );
477
478 return array_keys($DB->get_records('backup_ids_temp', $search, '', 'itemid'));
479 }
480
beb7de37
DM
481 /**
482 * Generates an artificial context id
483 *
484 * Moodle 1.9 backups do not contain any context information. But we need them
485 * in Moodle 2.x format so here we generate fictive context id for every given
486 * context level + instance combo.
487 *
26cac34a
DM
488 * CONTEXT_SYSTEM and CONTEXT_COURSE ignore the $instance as they represent a
489 * single system or the course being restored.
490 *
beb7de37
DM
491 * @see get_context_instance()
492 * @param int $level the context level, like CONTEXT_COURSE or CONTEXT_MODULE
493 * @param int $instance the instance id, for example $course->id for courses or $cm->id for activity modules
494 * @return int the context id
495 */
26cac34a 496 public function get_contextid($level, $instance = 0) {
beb7de37 497
9b5f1ad5 498 $stashname = 'context' . $level;
beb7de37 499
26cac34a
DM
500 if ($level == CONTEXT_SYSTEM or $level == CONTEXT_COURSE) {
501 $instance = 0;
502 }
503
d5d02635
DM
504 try {
505 // try the previously stashed id
506 return $this->get_stash($stashname, $instance);
beb7de37 507
d5d02635 508 } catch (moodle1_convert_empty_storage_exception $e) {
beb7de37 509 // this context level + instance is required for the first time
26cac34a
DM
510 $newid = $this->get_nextid();
511 $this->set_stash($stashname, $newid, $instance);
512 return $newid;
9b5f1ad5 513 }
beb7de37 514 }
179982a4 515
6700d288
DM
516 /**
517 * Simple autoincrement generator
518 *
519 * @return int the next number in a row of numbers
520 */
521 public function get_nextid() {
23007e5d 522 return $this->nextid++;
6700d288
DM
523 }
524
66f79e50
DM
525 /**
526 * Creates and returns new instance of the file manager
527 *
528 * @param int $contextid the default context id of the files being migrated
529 * @param string $component the default component name of the files being migrated
530 * @param string $filearea the default file area of the files being migrated
531 * @param int $itemid the default item id of the files being migrated
532 * @param int $userid initial user id of the files being migrated
533 * @return moodle1_file_manager
534 */
535 public function get_file_manager($contextid = null, $component = null, $filearea = null, $itemid = 0, $userid = null) {
536 return new moodle1_file_manager($this, $contextid, $component, $filearea, $itemid, $userid);
537 }
538
c818e2df
DM
539 /**
540 * Detects all links to file.php encoded via $@FILEPHP@$ and returns the files to migrate
541 *
542 * @param string $text
543 * @return array
544 */
545 public static function find_referenced_files($text) {
546
547 $files = array();
548
549 if (empty($text) or is_numeric($text)) {
550 return $files;
551 }
552
553 $matches = array();
554 $pattern = '|(["\'])(\$@FILEPHP@\$.+?)\1|';
555 $result = preg_match_all($pattern, $text, $matches);
556 if ($result === false) {
557 throw new moodle1_convert_exception('error_while_searching_for_referenced_files');
558 }
559 if ($result == 0) {
560 return $files;
561 }
562 foreach ($matches[2] as $match) {
563 $files[] = str_replace(array('$@FILEPHP@$', '$@SLASH@$', '$@FORCEDOWNLOAD@$'), array('', '/', ''), $match);
564 }
565
566 return array_unique($files);
567 }
568
569 /**
570 * Given the list of migrated files, rewrites references to them from $@FILEPHP@$ form to the @@PLUGINFILE@@ one
571 *
572 * @param string $text
573 * @param array $files
574 * @return string
575 */
576 public static function rewrite_filephp_usage($text, array $files) {
577
578 foreach ($files as $file) {
579 $fileref = '$@FILEPHP@$'.str_replace('/', '$@SLASH@$', $file);
580 $text = str_replace($fileref.'$@FORCEDOWNLOAD@$', '@@PLUGINFILE@@'.$file.'?forcedownload=1', $text);
581 $text = str_replace($fileref, '@@PLUGINFILE@@'.$file, $text);
582 }
583
584 return $text;
585 }
586
179982a4
DM
587 /**
588 * @see parent::description()
589 */
590 public static function description() {
591
592 return array(
593 'from' => backup::FORMAT_MOODLE1,
594 'to' => backup::FORMAT_MOODLE,
595 'cost' => 10,
596 );
597 }
1e2c7351
DM
598}
599
600
9b5f1ad5
DM
601/**
602 * Exception thrown by this converter
603 */
604class moodle1_convert_exception extends convert_exception {
605}
606
607
608/**
609 * Exception thrown by the temporary storage subsystem of moodle1_converter
610 */
611class moodle1_convert_storage_exception extends moodle1_convert_exception {
612}
613
614
615/**
616 * Exception thrown by the temporary storage subsystem of moodle1_converter
617 */
618class moodle1_convert_empty_storage_exception extends moodle1_convert_exception {
619}
620
621
1e2c7351 622/**
96f7c7ad 623 * XML parser processor used for processing parsed moodle.xml
1e2c7351
DM
624 */
625class moodle1_parser_processor extends grouped_parser_processor {
626
627 /** @var moodle1_converter */
628 protected $converter;
629
630 public function __construct(moodle1_converter $converter) {
631 $this->converter = $converter;
632 parent::__construct();
633 }
634
635 /**
8312ab67
DM
636 * Provides NULL decoding
637 *
638 * Note that we do not decode $@FILEPHP@$ and friends here as we are going to write them
639 * back immediately into another XML file.
1e2c7351
DM
640 */
641 public function process_cdata($cdata) {
1e2c7351 642
8312ab67 643 if ($cdata === '$@NULL@$') {
1e2c7351 644 return null;
1e2c7351 645 }
8312ab67
DM
646
647 return $cdata;
1e2c7351
DM
648 }
649
1e2c7351
DM
650 /**
651 * Dispatches the data chunk to the converter class
652 *
653 * @param array $data the chunk of parsed data
654 */
655 protected function dispatch_chunk($data) {
656 $this->converter->process_chunk($data);
657 }
658
659 /**
660 * Informs the converter at the start of a watched path
661 *
662 * @param string $path
663 */
664 protected function notify_path_start($path) {
665 $this->converter->path_start_reached($path);
666 }
667
668 /**
669 * Informs the converter at the end of a watched path
670 *
671 * @param string $path
672 */
673 protected function notify_path_end($path) {
674 $this->converter->path_end_reached($path);
675 }
676}
677
678
96f7c7ad
DM
679/**
680 * XML transformer that modifies the content of the files being written during the conversion
681 *
682 * @see backup_xml_transformer
683 */
684class moodle1_xml_transformer extends xml_contenttransformer {
685
686 /**
687 * Modify the content before it is writter to a file
688 *
689 * @param string|mixed $content
690 */
691 public function process($content) {
692
693 // the content should be a string. If array or object is given, try our best recursively
694 // but inform the developer
695 if (is_array($content)) {
696 debugging('Moodle1 XML transformer should not process arrays but plain content always', DEBUG_DEVELOPER);
697 foreach($content as $key => $plaincontent) {
698 $content[$key] = $this->process($plaincontent);
699 }
700 return $content;
701
702 } else if (is_object($content)) {
703 debugging('Moodle1 XML transformer should not process objects but plain content always', DEBUG_DEVELOPER);
704 foreach((array)$content as $key => $plaincontent) {
705 $content[$key] = $this->process($plaincontent);
706 }
707 return (object)$content;
708 }
709
710 // try to deal with some trivial cases first
711 if (is_null($content)) {
712 return '$@NULL@$';
713
714 } else if ($content === '') {
715 return '';
716
717 } else if (is_numeric($content)) {
718 return $content;
719
720 } else if (strlen($content) < 32) {
721 return $content;
722 }
723
96f7c7ad
DM
724 return $content;
725 }
726}
727
728
1e2c7351
DM
729/**
730 * Class representing a path to be converted from XML file
731 *
732 * This was created as a copy of {@link restore_path_element} and should be refactored
733 * probably.
734 */
735class convert_path {
736
737 /** @var string name of the element */
738 protected $name;
739
740 /** @var string path within the XML file this element will handle */
741 protected $path;
742
743 /** @var bool flag to define if this element will get child ones grouped or no */
744 protected $grouped;
745
746 /** @var object object instance in charge of processing this element. */
747 protected $pobject = null;
748
749 /** @var string the name of the processing method */
750 protected $pmethod = null;
751
46ff8b0e
DM
752 /** @var string the name of the path start event handler */
753 protected $smethod = null;
754
755 /** @var string the name of the path end event handler */
756 protected $emethod = null;
757
1e2c7351
DM
758 /** @var mixed last data read for this element or returned data by processing method */
759 protected $data = null;
760
a5fe5912
DM
761 /** @var array of deprecated fields that are dropped */
762 protected $dropfields = array();
1e2c7351
DM
763
764 /** @var array of fields renaming */
765 protected $renamefields = array();
766
767 /** @var array of new fields to add and their initial values */
768 protected $newfields = array();
769
770 /**
771 * Constructor
772 *
773 * @param string $name name of the element
774 * @param string $path path of the element
775 * @param array $recipe basic description of the structure conversion
776 * @param bool $grouped to gather information in grouped mode or no
777 */
778 public function __construct($name, $path, array $recipe = array(), $grouped = false) {
779
780 $this->validate_name($name);
781
782 $this->name = $name;
783 $this->path = $path;
784 $this->grouped = $grouped;
785
46ff8b0e 786 // set the default method names
1e2c7351 787 $this->set_processing_method('process_' . $name);
46ff8b0e
DM
788 $this->set_start_method('on_'.$name.'_start');
789 $this->set_end_method('on_'.$name.'_end');
1e2c7351 790
034b0e4a
DM
791 if ($grouped and !empty($recipe)) {
792 throw new convert_path_exception('recipes_not_supported_for_grouped_elements');
793 }
794
a5fe5912
DM
795 if (isset($recipe['dropfields']) and is_array($recipe['dropfields'])) {
796 $this->set_dropped_fields($recipe['dropfields']);
1e2c7351
DM
797 }
798 if (isset($recipe['renamefields']) and is_array($recipe['renamefields'])) {
799 $this->set_renamed_fields($recipe['renamefields']);
800 }
801 if (isset($recipe['newfields']) and is_array($recipe['newfields'])) {
802 $this->set_new_fields($recipe['newfields']);
803 }
804 }
805
806 /**
807 * Validates and sets the given processing object
808 *
809 * @param object $pobject processing object, must provide a method to be called
810 */
811 public function set_processing_object($pobject) {
812 $this->validate_pobject($pobject);
813 $this->pobject = $pobject;
814 }
815
816 /**
817 * Sets the name of the processing method
818 *
819 * @param string $pmethod
820 */
821 public function set_processing_method($pmethod) {
822 $this->pmethod = $pmethod;
823 }
824
46ff8b0e
DM
825 /**
826 * Sets the name of the path start event listener
827 *
828 * @param string $smethod
829 */
830 public function set_start_method($smethod) {
831 $this->smethod = $smethod;
832 }
833
834 /**
835 * Sets the name of the path end event listener
836 *
837 * @param string $emethod
838 */
839 public function set_end_method($emethod) {
840 $this->emethod = $emethod;
841 }
842
1e2c7351
DM
843 /**
844 * Sets the element data
845 *
846 * @param mixed
847 */
848 public function set_data($data) {
849 $this->data = $data;
850 }
851
852 /**
a5fe5912 853 * Sets the list of deprecated fields to drop
1e2c7351
DM
854 *
855 * @param array $fields
856 */
a5fe5912
DM
857 public function set_dropped_fields(array $fields) {
858 $this->dropfields = $fields;
1e2c7351
DM
859 }
860
861 /**
862 * Sets the required new names of the current fields
863 *
864 * @param array $fields (string)$currentname => (string)$newname
865 */
866 public function set_renamed_fields(array $fields) {
867 $this->renamefields = $fields;
868 }
869
870 /**
871 * Sets the new fields and their values
872 *
873 * @param array $fields (string)$field => (mixed)value
874 */
875 public function set_new_fields(array $fields) {
876 $this->newfields = $fields;
877 }
878
879 /**
880 * Cooks the parsed tags data by applying known recipes
881 *
882 * Recipes are used for common trivial operations like adding new fields
883 * or renaming fields. The handler's processing method receives cooked
884 * data.
885 *
886 * @param array $data the contents of the element
887 * @return array
888 */
889 public function apply_recipes(array $data) {
890
891 $cooked = array();
892
893 foreach ($data as $name => $value) {
894 // lower case rocks!
895 $name = strtolower($name);
896
034b0e4a
DM
897 if (is_array($value)) {
898 if ($this->is_grouped()) {
899 $value = $this->apply_recipes($value);
900 } else {
901 throw new convert_path_exception('non_grouped_path_with_array_values');
902 }
903 }
904
a5fe5912
DM
905 // drop legacy fields
906 if (in_array($name, $this->dropfields)) {
907 continue;
908 }
909
1e2c7351
DM
910 // fields renaming
911 if (array_key_exists($name, $this->renamefields)) {
912 $name = $this->renamefields[$name];
913 }
914
915 $cooked[$name] = $value;
916 }
917
918 // adding new fields
919 foreach ($this->newfields as $name => $value) {
920 $cooked[$name] = $value;
921 }
922
923 return $cooked;
924 }
925
926 /**
927 * @return string the element given name
928 */
929 public function get_name() {
930 return $this->name;
931 }
932
933 /**
934 * @return string the path to the element
935 */
936 public function get_path() {
937 return $this->path;
938 }
939
940 /**
941 * @return bool flag to define if this element will get child ones grouped or no
942 */
943 public function is_grouped() {
944 return $this->grouped;
945 }
946
947 /**
948 * @return object the processing object providing the processing method
949 */
950 public function get_processing_object() {
951 return $this->pobject;
952 }
953
954 /**
955 * @return string the name of the method to call to process the element
956 */
957 public function get_processing_method() {
958 return $this->pmethod;
959 }
960
46ff8b0e
DM
961 /**
962 * @return string the name of the path start event listener
963 */
964 public function get_start_method() {
965 return $this->smethod;
966 }
967
968 /**
969 * @return string the name of the path end event listener
970 */
971 public function get_end_method() {
972 return $this->emethod;
973 }
974
1e2c7351
DM
975 /**
976 * @return mixed the element data
977 */
978 public function get_data() {
979 return $this->data;
980 }
981
982
983 /// end of public API //////////////////////////////////////////////////////
984
985 /**
986 * Makes sure the given name is a valid element name
987 *
988 * Note it may look as if we used exceptions for code flow control here. That's not the case
989 * as we actually validate the code, not the user data. And the code is supposed to be
990 * correct.
991 *
992 * @param string @name the element given name
993 * @throws convert_path_exception
994 * @return void
995 */
996 protected function validate_name($name) {
997 // Validate various name constraints, throwing exception if needed
998 if (empty($name)) {
999 throw new convert_path_exception('convert_path_emptyname', $name);
1000 }
1001 if (preg_replace('/\s/', '', $name) != $name) {
1002 throw new convert_path_exception('convert_path_whitespace', $name);
1003 }
1004 if (preg_replace('/[^\x30-\x39\x41-\x5a\x5f\x61-\x7a]/', '', $name) != $name) {
1005 throw new convert_path_exception('convert_path_notasciiname', $name);
1006 }
1007 }
1008
1009 /**
1010 * Makes sure that the given object is a valid processing object
1011 *
46ff8b0e
DM
1012 * The processing object must be an object providing at least element's processing method
1013 * or path-reached-end event listener or path-reached-start listener method.
1014 *
1e2c7351
DM
1015 * Note it may look as if we used exceptions for code flow control here. That's not the case
1016 * as we actually validate the code, not the user data. And the code is supposed to be
1017 * correct.
1018 *
1019 * @param object $pobject
1020 * @throws convert_path_exception
1021 * @return void
1022 */
1023 protected function validate_pobject($pobject) {
1024 if (!is_object($pobject)) {
46ff8b0e 1025 throw new convert_path_exception('convert_path_no_object', get_class($pobject));
1e2c7351 1026 }
46ff8b0e
DM
1027 if (!method_exists($pobject, $this->get_processing_method()) and
1028 !method_exists($pobject, $this->get_end_method()) and
1029 !method_exists($pobject, $this->get_start_method())) {
1030 throw new convert_path_exception('convert_path_missing_method', get_class($pobject));
1e2c7351
DM
1031 }
1032 }
1033}
1034
1035
1036/**
1037 * Exception being thrown by {@link convert_path} methods
1038 */
1039class convert_path_exception extends moodle_exception {
1040
1041 /**
1042 * Constructor
1043 *
1044 * @param string $errorcode key for the corresponding error string
1045 * @param mixed $a extra words and phrases that might be required by the error string
1046 * @param string $debuginfo optional debugging information
1047 */
1048 public function __construct($errorcode, $a = null, $debuginfo = null) {
1049 parent::__construct($errorcode, '', '', $a, $debuginfo);
1050 }
1051}
66f79e50
DM
1052
1053
1054/**
1055 * The class responsible for files migration
1056 *
1057 * The files in Moodle 1.9 backup are stored in moddata, user_files, group_files,
1058 * course_files and site_files folders.
1059 */
1060class moodle1_file_manager {
1061
1062 /** @var moodle1_converter instance we serve to */
1063 public $converter;
1064
1065 /** @var int context id of the files being migrated */
1066 public $contextid;
1067
1068 /** @var string component name of the files being migrated */
1069 public $component;
1070
1071 /** @var string file area of the files being migrated */
1072 public $filearea;
1073
1074 /** @var int item id of the files being migrated */
1075 public $itemid = 0;
1076
1077 /** @var int user id */
1078 public $userid;
1079
214c4924
DM
1080 /** @var string the root of the converter temp directory */
1081 protected $basepath;
1082
66f79e50
DM
1083 /** @var textlib instance used during the migration */
1084 protected $textlib;
1085
1086 /** @var array of file ids that were migrated by this instance */
1087 protected $fileids = array();
1088
1089 /**
1090 * Constructor optionally accepting some default values for the migrated files
1091 *
1092 * @param moodle1_converter $converter the converter instance we serve to
1093 * @param int $contextid initial context id of the files being migrated
1094 * @param string $component initial component name of the files being migrated
1095 * @param string $filearea initial file area of the files being migrated
1096 * @param int $itemid initial item id of the files being migrated
1097 * @param int $userid initial user id of the files being migrated
1098 */
1099 public function __construct(moodle1_converter $converter, $contextid = null, $component = null, $filearea = null, $itemid = 0, $userid = null) {
214c4924 1100 // set the initial destination of the migrated files
66f79e50
DM
1101 $this->converter = $converter;
1102 $this->contextid = $contextid;
1103 $this->component = $component;
1104 $this->filearea = $filearea;
1105 $this->itemid = $itemid;
1106 $this->userid = $userid;
214c4924
DM
1107 // set other useful bits
1108 $this->basepath = $converter->get_tempdir_path();
66f79e50
DM
1109 $this->textlib = textlib_get_instance();
1110 }
1111
1112 /**
1113 * Migrates one given file stored on disk
1114 *
214c4924 1115 * @param string $sourcepath the path to the source local file within the backup archive {@example 'moddata/foobar/file.ext'}
aa97e0dd 1116 * @param string $filepath the file path of the migrated file, defaults to the root directory '/' {@example '/sub/dir/'}
66f79e50 1117 * @param string $filename the name of the migrated file, defaults to the same as the source file has
aa97e0dd 1118 * @param int $sortorder the sortorder of the file (main files have sortorder set to 1)
66f79e50
DM
1119 * @param int $timecreated override the timestamp of when the migrated file should appear as created
1120 * @param int $timemodified override the timestamp of when the migrated file should appear as modified
1121 * @return int id of the migrated file
1122 */
aa97e0dd 1123 public function migrate_file($sourcepath, $filepath = '/', $filename = null, $sortorder = 0, $timecreated = null, $timemodified = null) {
214c4924
DM
1124
1125 $sourcefullpath = $this->basepath.'/'.$sourcepath;
66f79e50
DM
1126
1127 if (!is_readable($sourcefullpath)) {
214c4924 1128 throw new moodle1_convert_exception('file_not_readable', $sourcefullpath);
66f79e50
DM
1129 }
1130
aa97e0dd
DM
1131 // sanitize filepath
1132 if (empty($filepath)) {
1133 $filepath = '/';
1134 }
1135 if (substr($filepath, -1) !== '/') {
1136 $filepath .= '/';
1137 }
66f79e50
DM
1138 $filepath = clean_param($filepath, PARAM_PATH);
1139
1140 if ($this->textlib->strlen($filepath) > 255) {
1141 throw new moodle1_convert_exception('file_path_longer_than_255_chars');
1142 }
1143
1144 if (is_null($filename)) {
1145 $filename = basename($sourcefullpath);
1146 }
1147
1148 $filename = clean_param($filename, PARAM_FILE);
1149
1150 if ($filename === '') {
1151 throw new moodle1_convert_exception('unsupported_chars_in_filename');
1152 }
1153
1154 if (is_null($timecreated)) {
1155 $timecreated = filectime($sourcefullpath);
1156 }
1157
1158 if (is_null($timemodified)) {
1159 $timemodified = filemtime($sourcefullpath);
1160 }
1161
1162 $filerecord = $this->make_file_record(array(
1163 'filepath' => $filepath,
1164 'filename' => $filename,
aa97e0dd 1165 'sortorder' => $sortorder,
66f79e50
DM
1166 'mimetype' => mimeinfo('type', $sourcefullpath),
1167 'timecreated' => $timecreated,
1168 'timemodified' => $timemodified,
1169 ));
1170
1171 list($filerecord['contenthash'], $filerecord['filesize'], $newfile) = $this->add_file_to_pool($sourcefullpath);
1172 $this->stash_file($filerecord);
1173
1174 return $filerecord['id'];
1175 }
1176
1177 /**
1178 * Migrates all files in the given directory
1179 *
214c4924 1180 * @param string $rootpath path within the backup archive to the root directory containing the files {@example 'course_files'}
66f79e50 1181 * @param string $relpath relative path used during the recursion - do not provide when calling this!
93264625 1182 * @return array ids of the migrated files, empty array if the $rootpath not found
66f79e50
DM
1183 */
1184 public function migrate_directory($rootpath, $relpath='/') {
1185
93264625
DM
1186 if (!file_exists($this->basepath.'/'.$rootpath.$relpath)) {
1187 return array();
1188 }
1189
66f79e50
DM
1190 $fileids = array();
1191
1192 // make the fake file record for the directory itself
1193 $filerecord = $this->make_file_record(array('filepath' => $relpath, 'filename' => '.'));
1194 $this->stash_file($filerecord);
1195 $fileids[] = $filerecord['id'];
1196
214c4924 1197 $items = new DirectoryIterator($this->basepath.'/'.$rootpath.$relpath);
66f79e50
DM
1198
1199 foreach ($items as $item) {
1200
1201 if ($item->isDot()) {
1202 continue;
1203 }
1204
1205 if ($item->isLink()) {
1206 throw new moodle1_convert_exception('unexpected_symlink');
1207 }
1208
1209 if ($item->isFile()) {
214c4924 1210 $fileids[] = $this->migrate_file(substr($item->getPathname(), strlen($this->basepath.'/')),
aa97e0dd 1211 $relpath, $item->getFilename(), 0, $item->getCTime(), $item->getMTime());
66f79e50
DM
1212
1213 } else {
1214 $dirname = clean_param($item->getFilename(), PARAM_PATH);
1215
1216 if ($dirname === '') {
1217 throw new moodle1_convert_exception('unsupported_chars_in_filename');
1218 }
1219
1220 // migrate subdirectories recursively
1221 $fileids = array_merge($fileids, $this->migrate_directory($rootpath, $relpath.$item->getFilename().'/'));
1222 }
1223 }
1224
1225 return $fileids;
1226 }
1227
1228 /**
1229 * Returns the list of all file ids migrated by this instance so far
1230 *
1231 * @return array of int
1232 */
1233 public function get_fileids() {
1234 return $this->fileids;
1235 }
1236
d61ed0af
DM
1237 /**
1238 * Explicitly clear the list of file ids migrated by this instance so far
1239 */
1240 public function reset_fileids() {
1241 $this->fileids = array();
1242 }
1243
66f79e50
DM
1244 /// internal implementation details ////////////////////////////////////////
1245
1246 /**
1247 * Prepares a fake record from the files table
1248 *
1249 * @param array $fileinfo explicit file data
1250 * @return array
1251 */
1252 protected function make_file_record(array $fileinfo) {
1253
1254 $defaultrecord = array(
1255 'contenthash' => 'da39a3ee5e6b4b0d3255bfef95601890afd80709', // sha1 of an empty file
1256 'contextid' => $this->contextid,
1257 'component' => $this->component,
1258 'filearea' => $this->filearea,
1259 'itemid' => $this->itemid,
1260 'filepath' => null,
1261 'filename' => null,
1262 'filesize' => 0,
1263 'userid' => $this->userid,
1264 'mimetype' => null,
1265 'status' => 0,
1266 'timecreated' => $now = time(),
1267 'timemodified' => $now,
1268 'source' => null,
1269 'author' => null,
1270 'license' => null,
1271 'sortorder' => 0,
1272 );
1273
1274 if (!array_key_exists('id', $fileinfo)) {
1275 $defaultrecord['id'] = $this->converter->get_nextid();
1276 }
1277
1278 // override the default values with the explicit data provided and return
1279 return array_merge($defaultrecord, $fileinfo);
1280 }
1281
1282 /**
1283 * Copies the given file to the pool directory
1284 *
1285 * Returns an array containing SHA1 hash of the file contents, the file size
1286 * and a flag indicating whether the file was actually added to the pool or whether
1287 * it was already there.
1288 *
1289 * @param string $pathname the full path to the file
1290 * @return array with keys (string)contenthash, (int)filesize, (bool)newfile
1291 */
1292 protected function add_file_to_pool($pathname) {
1293
1294 if (!is_readable($pathname)) {
1295 throw new moodle1_convert_exception('file_not_readable');
1296 }
1297
1298 $contenthash = sha1_file($pathname);
1299 $filesize = filesize($pathname);
1300 $hashpath = $this->converter->get_workdir_path().'/files/'.substr($contenthash, 0, 2);
1301 $hashfile = "$hashpath/$contenthash";
1302
1303 if (file_exists($hashfile)) {
1304 if (filesize($hashfile) !== $filesize) {
1305 // congratulations! you have found two files with different size and the same
1306 // content hash. or, something were wrong (which is more likely)
1307 throw new moodle1_convert_exception('same_hash_different_size');
1308 }
1309 $newfile = false;
1310
1311 } else {
1312 check_dir_exists($hashpath);
1313 $newfile = true;
1314
1315 if (!copy($pathname, $hashfile)) {
1316 throw new moodle1_convert_exception('unable_to_copy_file');
1317 }
1318
1319 if (filesize($hashfile) !== $filesize) {
1320 throw new moodle1_convert_exception('filesize_different_after_copy');
1321 }
1322 }
1323
1324 return array($contenthash, $filesize, $newfile);
1325 }
1326
1327 /**
1328 * Stashes the file record into 'files' stash and adds the record id to list of migrated files
1329 *
1330 * @param array $filerecord
1331 */
1332 protected function stash_file(array $filerecord) {
1333 $this->converter->set_stash('files', $filerecord, $filerecord['id']);
1334 $this->fileids[] = $filerecord['id'];
1335 }
1336}