Improved new export-import by adding time log.
[timetracker.git] / WEB-INF / lib / ttOrgImportHelper.class.php
1 <?php
2 // +----------------------------------------------------------------------+
3 // | Anuko Time Tracker
4 // +----------------------------------------------------------------------+
5 // | Copyright (c) Anuko International Ltd. (https://www.anuko.com)
6 // +----------------------------------------------------------------------+
7 // | LIBERAL FREEWARE LICENSE: This source code document may be used
8 // | by anyone for any purpose, and freely redistributed alone or in
9 // | combination with other software, provided that the license is obeyed.
10 // |
11 // | There are only two ways to violate the license:
12 // |
13 // | 1. To redistribute this code in source form, with the copyright
14 // |    notice or license removed or altered. (Distributing in compiled
15 // |    forms without embedded copyright notices is permitted).
16 // |
17 // | 2. To redistribute modified versions of this code in *any* form
18 // |    that bears insufficient indications that the modifications are
19 // |    not the work of the original author(s).
20 // |
21 // | This license applies to this document only, not any other software
22 // | that it may be combined with.
23 // |
24 // +----------------------------------------------------------------------+
25 // | Contributors:
26 // | https://www.anuko.com/time_tracker/credits.htm
27 // +----------------------------------------------------------------------+
28
29 import('ttUserHelper');
30 import('ttRoleHelper');
31 import('ttTaskHelper');
32 import('ttProjectHelper');
33 import('ttClientHelper');
34 import('ttInvoiceHelper');
35
36 // ttOrgImportHelper - this class is a future replacement for ttImportHelper.
37 // Currently, it is work in progress.
38 // When done, it should handle import of complex groups consisting of other groups.
39 class ttOrgImportHelper {
40   var $errors               = null; // Errors go here. Set in constructor by reference.
41   var $conflicting_entities = null; // A comma-separated list of entity names we cannot import.
42   var $canImport      = true;    // False if we cannot import data due to a conflict such as login collision.
43   var $firstPass      = true;    // True during first pass through the file.
44   var $org_id         = null;    // Organization id (same as top group_id).
45   var $current_group_id        = null; // Current group id during parsing.
46   var $current_parent_group_id = null; // Current parent group id during parsing.
47                                        // Set when we create a new group.
48   var $top_role_id    = 0;       // Top role id.
49
50   // Entity maps for current group. They map XML ids with database ids.
51   var $currentGroupRoleMap    = array();
52   var $currentGroupTaskMap    = array();
53   var $currentGroupProjectMap = array();
54   var $currentGroupClientMap  = array();
55   var $currentGroupUserMap    = array();
56   var $currentGroupInvoiceMap = array();
57   var $currentGroupLogMap     = array();
58
59   // Constructor.
60   function __construct(&$errors) {
61     $this->errors = &$errors;
62     $this->top_role_id = ttRoleHelper::getRoleByRank(512, 0);
63   }
64
65   // startElement - callback handler for opening tag of an XML element in the file.
66   function startElement($parser, $name, $attrs) {
67     global $i18n;
68
69     // First pass. We only check user logins for potential collisions with existing.
70     if ($this->firstPass) {
71       if ($name == 'USER' && $this->canImport) {
72         $login = $attrs['LOGIN'];
73         if ('' != $attrs['STATUS'] && ttUserHelper::getUserByLogin($login)) {
74           // We have a login collision. Append colliding login to a list of things we cannot import.
75           $this->conflicting_entities .= ($this->conflicting_entities ? ", $login" : $login);
76         }
77       }
78     }
79
80     // Second pass processing. We import data here, one tag at a time.
81     if (!$this->firstPass && $this->canImport && $this->errors->no()) {
82       $mdb2 = getConnection();
83
84       // We are in second pass and can import data.
85       if ($name == 'GROUP') {
86         // Create a new group.
87         $this->current_group_id = $this->createGroup(array(
88           'parent_id' => $this->current_parent_group_id,
89           'org_id' => $this->org_id,
90           'name' => $attrs['NAME'],
91           'currency' => $attrs['CURRENCY'],
92           'decimal_mark' => $attrs['DECIMAL_MARK'],
93           'lang' => $attrs['LANG'],
94           'date_format' => $attrs['DATE_FORMAT'],
95           'time_format' => $attrs['TIME_FORMAT'],
96           'week_start' => $attrs['WEEK_START'],
97           'tracking_mode' => $attrs['TRACKING_MODE'],
98           'project_required' => $attrs['PROJECT_REQUIRED'],
99           'task_required' => $attrs['TASK_REQUIRED'],
100           'record_type' => $attrs['RECORD_TYPE'],
101           'bcc_email' => $attrs['BCC_EMAIL'],
102           'allow_ip' => $attrs['ALLOW_IP'],
103           'password_complexity' => $attrs['PASSWORD_COMPLEXITY'],
104           'plugins' => $attrs['PLUGINS'],
105           'lock_spec' => $attrs['LOCK_SPEC'],
106           'workday_minutes' => $attrs['WORKDAY_MINUTES'],
107           'custom_logo' => $attrs['CUSTOM_LOGO'],
108           'config' => $attrs['CONFIG']));
109
110         // Special handling for top group.
111         if (!$this->org_id && $this->current_group_id) {
112           $this->org_id = $this->current_group_id;
113           $sql = "update tt_groups set org_id = $this->current_group_id where org_id is NULL and id = $this->current_group_id";
114           $affected = $mdb2->exec($sql);
115         }
116         // Set parent group to create subgroups with this group as parent at next entry here.
117         $this->current_parent_group_id = $this->current_group_id;
118       }
119
120       if ($name == 'ROLES') {
121         // If we get here, we have to recycle $currentGroupRoleMap.
122         unset($this->currentGroupRoleMap);
123         $this->currentGroupRoleMap = array();
124         // Role map is reconstructed after processing <role> elements in XML. See below.
125       }
126
127       if ($name == 'ROLE') {
128         // We get here when processing <role> tags for the current group.
129         $role_id = ttRoleHelper::insert(array(
130               'group_id' => $this->current_group_id,
131               'org_id' => $this->org_id,
132               'name' => $attrs['NAME'],
133               'description' => $attrs['DESCRIPTION'],
134               'rank' => $attrs['RANK'],
135               'rights' => $attrs['RIGHTS'],
136               'status' => $attrs['STATUS']));
137         if ($role_id) {
138           // Add a mapping.
139           $this->currentGroupRoleMap[$attrs['ID']] = $role_id;
140         } else $this->errors->add($i18n->get('error.db'));
141       }
142
143       if ($name == 'TASKS') {
144         // If we get here, we have to recycle $currentGroupTaskMap.
145         unset($this->currentGroupTaskMap);
146         $this->currentGroupTaskMap = array();
147         // Task map is reconstructed after processing <task> elements in XML. See below.
148       }
149
150       if ($name == 'TASK') {
151         // We get here when processing <task> tags for the current group.
152         $task_id = ttTaskHelper::insert(array(
153           'group_id' => $this->current_group_id,
154           'org_id' => $this->org_id,
155           'name' => $attrs['NAME'],
156           'description' => $attrs['DESCRIPTION'],
157           'status' => $attrs['STATUS']));
158         if ($task_id) {
159           // Add a mapping.
160           $this->currentGroupTaskMap[$attrs['ID']] = $task_id;
161         } else $this->errors->add($i18n->get('error.db'));
162       }
163
164       if ($name == 'PROJECTS') {
165         // If we get here, we have to recycle $currentGroupProjectMap.
166         unset($this->currentGroupProjectMap);
167         $this->currentGroupProjectMap = array();
168         // Project map is reconstructed after processing <project> elements in XML. See below.
169       }
170
171       if ($name == 'PROJECT') {
172         // We get here when processing <project> tags for the current group.
173
174         // Prepare a list of task ids.
175         $tasks = explode(',', $attrs['TASKS']);
176         foreach ($tasks as $id)
177           $mapped_tasks[] = $this->currentGroupTaskMap[$id];
178
179         $project_id = ttProjectHelper::insert(array(
180           'group_id' => $this->current_group_id,
181           'org_id' => $this->org_id,
182           'name' => $attrs['NAME'],
183           'description' => $attrs['DESCRIPTION'],
184           'tasks' => $mapped_tasks,
185           'status' => $attrs['STATUS']));
186         if ($project_id) {
187           // Add a mapping.
188           $this->currentGroupProjectMap[$attrs['ID']] = $project_id;
189         } else $this->errors->add($i18n->get('error.db'));
190       }
191
192       if ($name == 'CLIENTS') {
193         // If we get here, we have to recycle $currentGroupClientMap.
194         unset($this->currentGroupClientMap);
195         $this->currentGroupClientMap = array();
196         // Client map is reconstructed after processing <client> elements in XML. See below.
197       }
198
199       if ($name == 'CLIENT') {
200         // We get here when processing <client> tags for the current group.
201
202         // Prepare a list of project ids.
203         $projects = explode(',', $attrs['PROJECTS']);
204         foreach ($projects as $id)
205           $mapped_projects[] = $this->currentGroupProjectMap[$id];
206
207         $client_id = ttClientHelper::insert(array(
208           'group_id' => $this->current_group_id,
209           'org_id' => $this->org_id,
210           'name' => $attrs['NAME'],
211           'address' => $attrs['ADDRESS'],
212           'tax' => $attrs['TAX'],
213           'projects' => $mapped_projects,
214           'status' => $attrs['STATUS']));
215         if ($client_id) {
216           // Add a mapping.
217           $this->currentGroupClientMap[$attrs['ID']] = $client_id;
218         } else $this->errors->add($i18n->get('error.db'));
219       }
220
221       if ($name == 'USERS') {
222         // If we get here, we have to recycle $currentGroupUserMap.
223         unset($this->currentGroupUserMap);
224         $this->currentGroupUserMap = array();
225         // User map is reconstructed after processing <user> elements in XML. See below.
226       }
227
228       if ($name == 'USER') {
229         // We get here when processing <user> tags for the current group.
230
231         $role_id = $attrs['ROLE_ID'] === '0' ? $this->top_role_id :  $this->currentGroupRoleMap[$attrs['ROLE_ID']]; // 0 (not null) means top manager role.
232
233         $user_id = ttUserHelper::insert(array(
234           'group_id' => $this->current_group_id,
235           'org_id' => $this->org_id,
236           'role_id' => $role_id,
237           'client_id' => $this->currentGroupClientMap[$attrs['CLIENT_ID']],
238           'name' => $attrs['NAME'],
239           'login' => $attrs['LOGIN'],
240           'password' => $attrs['PASSWORD'],
241           'rate' => $attrs['RATE'],
242           'email' => $attrs['EMAIL'],
243           'status' => $attrs['STATUS']), false);
244         if ($user_id) {
245           // Add a mapping.
246           $this->currentGroupUserMap[$attrs['ID']] = $user_id;
247         } else $this->errors->add($i18n->get('error.db'));
248       }
249
250       if ($name == 'USER_PROJECT_BIND') {
251         if (!ttUserHelper::insertBind(array(
252           'user_id' => $this->currentGroupUserMap[$attrs['USER_ID']],
253           'project_id' => $this->currentGroupProjectMap[$attrs['PROJECT_ID']],
254           'group_id' => $this->current_group_id,
255           'org_id' => $this->org_id,
256           'rate' => $attrs['RATE'],
257           'status' => $attrs['STATUS']))) {
258           $this->errors->add($i18n->get('error.db'));
259         }
260       }
261
262       if ($name == 'INVOICES') {
263         // If we get here, we have to recycle $currentGroupInvoiceMap.
264         unset($this->currentGroupInvoiceMap);
265         $this->currentGroupInvoiceMap = array();
266         // Invoice map is reconstructed after processing <invoice> elements in XML. See below.
267       }
268
269       if ($name == 'INVOICE') {
270         // We get here when processing <invoice> tags for the current group.
271         $invoice_id = ttInvoiceHelper::insert(array(
272           'group_id' => $this->current_group_id,
273           'org_id' => $this->org_id,
274           'name' => $attrs['NAME'],
275           'date' => $attrs['DATE'],
276           'client_id' => $this->currentGroupClientMap[$attrs['CLIENT_ID']],
277           'status' => $attrs['STATUS']));
278         if ($invoice_id) {
279           // Add a mapping.
280           $this->currentGroupInvoiceMap[$attrs['ID']] = $invoice_id;
281         } else $this->errors->add($i18n->get('error.db'));
282       }
283
284       if ($name == 'LOG') {
285         // If we get here, we have to recycle $currentGroupLogMap.
286         unset($this->currentGroupLogMap);
287         $this->currentGroupLogMap = array();
288         // Log map is reconstructed after processing <log_item> elements in XML. See below.
289       }
290
291       if ($name == 'LOG_ITEM') {
292         // We get here when processing <log_item> tags for the current group.
293         $log_item_id = ttTimeHelper::insert(array(
294           'user_id' => $this->currentGroupUserMap[$attrs['USER_ID']],
295           'group_id' => $this->current_group_id,
296           'org_id' => $this->org_id,
297           'date' => $attrs['DATE'],
298           'start' => $attrs['START'],
299           'finish' => $attrs['FINISH'],
300           'duration' => $attrs['DURATION'],
301           'client' => $this->currentGroupClientMap[$attrs['CLIENT_ID']],
302           'project' => $this->currentGroupProjectMap[$attrs['PROJECT_ID']],
303           'task' => $this->currentGroupTaskMap[$attrs['TASK_ID']],
304           'invoice' => $this->currentGroupInvoiceMap[$attrs['INVOICE_ID']],
305           'note' => (isset($attrs['COMMENT']) ? $attrs['COMMENT'] : ''),
306           'billable' => $attrs['BILLABLE'],
307           'paid' => $attrs['PAID'],
308           'status' => $attrs['STATUS']));
309         if ($log_item_id) {
310           // Add a mapping.
311           $this->currentGroupLogMap[$attrs['ID']] = $log_item_id;
312         } else $this->errors->add($i18n->get('error.db'));
313       }
314     }
315   }
316
317   // importXml - uncompresses the file, reads and parses its content. During parsing,
318   // startElement, endElement, and dataElement functions are called as many times as necessary.
319   // Actual import occurs in the endElement handler.
320   function importXml() {
321     global $i18n;
322
323     // Do we have a compressed file?
324     $compressed = false;
325     $file_ext = substr($_FILES['xmlfile']['name'], strrpos($_FILES['xmlfile']['name'], '.') + 1);
326     if (in_array($file_ext, array('bz','tbz','bz2','tbz2'))) {
327       $compressed = true;
328     }
329
330     // Create a temporary file.
331     $dirName = dirname(TEMPLATE_DIR . '_c/.');
332     $filename = tempnam($dirName, 'import_');
333
334     // If the file is compressed - uncompress it.
335     if ($compressed) {
336       if (!$this->uncompress($_FILES['xmlfile']['tmp_name'], $filename)) {
337         $this->errors->add($i18n->get('error.sys'));
338         return;
339       }
340       unlink($_FILES['xmlfile']['tmp_name']);
341     } else {
342       if (!move_uploaded_file($_FILES['xmlfile']['tmp_name'], $filename)) {
343         $this->errors->add($i18n->get('error.upload'));
344         return;
345       }
346     }
347
348     // Initialize XML parser.
349     $parser = xml_parser_create();
350     xml_set_object($parser, $this);
351     xml_set_element_handler($parser, 'startElement', false);
352
353     // We need to parse the file 2 times:
354     //   1) First pass: determine if import is possible - there must be no login collisions.
355     //   2) Second pass: if we can import, then do import in a second pass.
356     // This is different from earlier approach for single group import, where we could
357     // do both things in one pass because user info was in the beginning of XML file.
358     // Now, with subgroups, users can be located anywhere in the file.
359
360     // Read and parse the content of the file. During parsing, startElement, endElement, and dataElement functions are called.
361     $file = fopen($filename, 'r');
362     while ($data = fread($file, 4096)) {
363       if (!xml_parse($parser, $data, feof($file))) {
364         $this->errors->add(sprintf($i18n->get('error.xml'),
365           xml_get_current_line_number($parser),
366           xml_error_string(xml_get_error_code($parser))));
367       }
368     }
369     if ($this->conflicting_entities) {
370       $this->canImport = false;
371       $this->errors->add($i18n->get('error.user_exists'));
372       $this->errors->add(sprintf($i18n->get('error.cannot_import'), $this->conflicting_entities));
373     }
374
375     $this->firstPass = false; // We are done with 1st pass.
376     xml_parser_free($parser);
377     if ($file) fclose($file);
378     if (!$this->canImport) {
379       unlink($filename);
380       return;
381     }
382     if ($this->errors->yes()) return; // Exit if we have errors.
383
384     // Now we can do a second pass, where real work is done.
385     $parser = xml_parser_create();
386     xml_set_object($parser, $this);
387     xml_set_element_handler($parser, 'startElement', false);
388
389     // Read and parse the content of the file. During parsing, startElement, endElement, and dataElement functions are called.
390     $file = fopen($filename, 'r');
391     while ($data = fread($file, 4096)) {
392       if (!xml_parse($parser, $data, feof($file))) {
393         $this->errors->add(sprintf($i18n->get('error.xml'),
394           xml_get_current_line_number($parser),
395           xml_error_string(xml_get_error_code($parser))));
396       }
397     }
398     xml_parser_free($parser);
399     if ($file) fclose($file);
400     unlink($filename);
401   }
402
403   // uncompress - uncompresses the content of the $in file into the $out file.
404   function uncompress($in, $out) {
405     // Do we have the uncompress function?
406     if (!function_exists('bzopen'))
407       return false;
408
409     // Initial checks of file names and permissions.
410     if (!file_exists($in) || !is_readable ($in))
411       return false;
412     if ((!file_exists($out) && !is_writable(dirname($out))) || (file_exists($out) && !is_writable($out)))
413       return false;
414
415     if (!$out_file = fopen($out, 'wb'))
416       return false;
417     if (!$in_file = bzopen ($in, 'r'))
418       return false;
419
420     while (!feof($in_file)) {
421       $buffer = bzread($in_file, 4096);
422       fwrite($out_file, $buffer, 4096);
423     }
424     bzclose($in_file);
425     fclose ($out_file);
426     return true;
427   }
428
429   // createGroup function creates a new group.
430   private function createGroup($fields) {
431     global $user;
432     global $i18n;
433     $mdb2 = getConnection();
434
435     $columns = '(parent_id, org_id, name, currency, decimal_mark, lang, date_format, time_format'.
436       ', week_start, tracking_mode, project_required, task_required, record_type, bcc_email'.
437       ', allow_ip, password_complexity, plugins, lock_spec'.
438       ', workday_minutes, config, created, created_ip, created_by)';
439
440     $values = ' values (';
441     $values .= $mdb2->quote($fields['parent_id']);
442     $values .= ', '.$mdb2->quote($fields['org_id']);
443     $values .= ', '.$mdb2->quote(trim($fields['name']));
444     $values .= ', '.$mdb2->quote(trim($fields['currency']));
445     $values .= ', '.$mdb2->quote($fields['decimal_mark']);
446     $values .= ', '.$mdb2->quote($fields['lang']);
447     $values .= ', '.$mdb2->quote($fields['date_format']);
448     $values .= ', '.$mdb2->quote($fields['time_format']);
449     $values .= ', '.(int)$fields['week_start'];
450     $values .= ', '.(int)$fields['tracking_mode'];
451     $values .= ', '.(int)$fields['project_required'];
452     $values .= ', '.(int)$fields['task_required'];
453     $values .= ', '.(int)$fields['record_type'];
454     $values .= ', '.$mdb2->quote($fields['bcc_email']);
455     $values .= ', '.$mdb2->quote($fields['allow_ip']);
456     $values .= ', '.$mdb2->quote($fields['password_complexity']);
457     $values .= ', '.$mdb2->quote($fields['plugins']);
458     $values .= ', '.$mdb2->quote($fields['lock_spec']);
459     $values .= ', '.(int)$fields['workday_minutes'];
460     $values .= ', '.$mdb2->quote($fields['config']);
461     $values .= ', now(), '.$mdb2->quote($_SERVER['REMOTE_ADDR']).', '.$mdb2->quote($user->id);
462     $values .= ')';
463
464     $sql = 'insert into tt_groups '.$columns.$values;
465     $affected = $mdb2->exec($sql);
466     if (is_a($affected, 'PEAR_Error')) {
467       $this->errors->add($i18n->get('error.db'));
468       return false;
469     }
470
471     $group_id = $mdb2->lastInsertID('tt_groups', 'id');
472     return $group_id;
473   }
474 }