diff --git a/application/excelexporter.class.inc.php b/application/excelexporter.class.inc.php new file mode 100644 index 000000000..f3b8da9ad --- /dev/null +++ b/application/excelexporter.class.inc.php @@ -0,0 +1,536 @@ +aStatistics = array( + 'objects_count' => 0, + 'total_duration' => 0, + 'data_retrieval_duration' => 0, + 'excel_build_duration' => 0, + 'excel_write_duration' => 0, + 'peak_memory_usage' => 0, + ); + $this->fStartTime = microtime(true); + $this->oSearch = null; + + $this->sState = 'new'; + $this->aObjectsIDs = array(); + $this->iPosition = 0; + $this->aAuthorizedClasses = null; + $this->aTableHeaders = null; + $this->sOutputFilePath = null; + $this->bAdvancedMode = false; + $this->CheckDataDir(); + if ($sToken == null) + { + $this->sToken = $this->GetNewToken(); + } + else + { + $this->sToken = $sToken; + $this->ReloadState(); + } + } + + public function __destruct() + { + if (($this->sState != 'done') && ($this->sState != 'error') && ($this->sToken != null)) + { + // Operation in progress, save the state + $this->SaveState(); + } + else + { + // Operation completed, cleanup the temp files + @unlink($this->GetStateFile()); + @unlink($this->GetDataFile()); + } + self::CleanupOldFiles(); + } + + public function SetChunkSize($iChunkSize) + { + $this->iChunkSize = $iChunkSize; + } + + public function SetOutputFilePath($sDestFilePath) + { + $this->sOutputFilePath = $sDestFilePath; + } + + public function SetAdvancedMode($bAdvanced) + { + $this->bAdvancedMode = $bAdvanced; + } + + public function SaveState() + { + $aState = array( + 'state' => $this->sState, + 'statistics' => $this->aStatistics, + 'filter' => $this->oSearch->serialize(), + 'position' => $this->iPosition, + 'chunk_size' => $this->iChunkSize, + 'object_ids' => $this->aObjectsIDs, + 'output_file_path' => $this->sOutputFilePath, + 'advanced_mode' => $this->bAdvancedMode, + ); + + file_put_contents($this->GetStateFile(), json_encode($aState)); + + return $this->sToken; + } + + public function ReloadState() + { + if ($this->sToken == null) + { + throw new Exception('ExcelExporter not initialized with a token, cannot reload state'); + } + + if (!file_exists($this->GetStateFile())) + { + throw new Exception("ExcelExporter: missing status file '".$this->GetStateFile()."', cannot reload state."); + } + $sJson = file_get_contents($this->GetStateFile()); + $aState = json_decode($sJson, true); + if ($aState === null) + { + throw new Exception("ExcelExporter:corrupted status file '".$this->GetStateFile()."', not a JSON, cannot reload state."); + } + + $this->sState = $aState['state']; + $this->aStatistics = $aState['statistics']; + $this->oSearch = DBObjectSearch::unserialize($aState['filter']); + $this->iPosition = $aState['position']; + $this->iChunkSize = $aState['chunk_size']; + $this->aObjectsIDs = $aState['object_ids']; + $this->sOutputFilePath = $aState['output_file_path']; + $this->bAdvancedMode = $aState['advanced_mode']; + } + + public function SetObjectList($oSearch) + { + $this->oSearch = $oSearch; + } + + public function Run() + { + $sCode = 'error'; + $iPercentage = 100; + $sMessage = Dict::Format('ExcelExporter:ErrorUnexpected_State', $this->sState); + $fTime = microtime(true); + + try + { + switch($this->sState) + { + case 'new': + $oIDSet = new DBObjectSet($this->oSearch); + $oIDSet->OptimizeColumnLoad(array('id')); + $this->aObjectsIDs = array(); + while($oObj = $oIDSet->Fetch()) + { + $this->aObjectsIDs[] = $oObj->GetKey(); + } + $sCode = 'retrieving-data'; + $iPercentage = 5; + $sMessage = Dict::S('ExcelExporter:RetrievingData'); + $this->iPosition = 0; + $this->aStatistics['objects_count'] = count($this->aObjectsIDs); + $this->aStatistics['data_retrieval_duration'] += microtime(true) - $fTime; + + // The first line of the file is the "headers" specifying the label and the type of each column + $this->GetFieldsList($oIDSet, $this->bAdvancedMode); + $sRow = json_encode($this->aTableHeaders); + $hFile = @fopen($this->GetDataFile(), 'ab'); + if ($hFile === false) + { + throw new Exception('ExcelExporter: Failed to open temporary data file: "'.$this->GetDataFile().'" for writing.'); + } + fwrite($hFile, $sRow."\n"); + fclose($hFile); + + // Next state + $this->sState = 'retrieving-data'; + break; + + case 'retrieving-data': + $oCurrentSearch = clone $this->oSearch; + $aIDs = array_slice($this->aObjectsIDs, $this->iPosition, $this->iChunkSize); + + $oCurrentSearch->AddCondition('id', $aIDs, 'IN'); + $hFile = @fopen($this->GetDataFile(), 'ab'); + if ($hFile === false) + { + throw new Exception('ExcelExporter: Failed to open temporary data file: "'.$this->GetDataFile().'" for writing.'); + } + $oSet = new DBObjectSet($oCurrentSearch); + $this->GetFieldsList($oSet, $this->bAdvancedMode); + while($aObjects = $oSet->FetchAssoc()) + { + $aRow = array(); + foreach($this->aAuthorizedClasses as $sAlias => $sClassName) + { + $oObj = $aObjects[$sAlias]; + if ($this->bAdvancedMode) + { + $aRow[] = $oObj->GetKey(); + } + foreach($this->aFieldsList[$sAlias] as $sAttCodeEx => $oAttDef) + { + $value = $oObj->Get($sAttCodeEx); + if ($value instanceOf ormCaseLog) + { + // Extract the case log as text and remove the "===" which make Excel think that the cell contains a formula the next time you edit it! + $sExcelVal = trim(preg_replace('/========== ([^=]+) ============/', '********** $1 ************', $value->GetText())); + } + else + { + $sExcelVal = $oAttDef->GetEditValue($value, $oObj); + } + $aRow[] = $sExcelVal; + } + } + $sRow = json_encode($aRow); + fwrite($hFile, $sRow."\n"); + } + fclose($hFile); + + if (($this->iPosition + $this->iChunkSize) > count($this->aObjectsIDs)) + { + // Next state + $this->sState = 'building-excel'; + $sCode = 'building-excel'; + $iPercentage = 80; + $sMessage = Dict::S('ExcelExporter:BuildingExcelFile'); + } + else + { + $sCode = 'retrieving-data'; + $this->iPosition += $this->iChunkSize; + $iPercentage = 5 + round(75 * ($this->iPosition / count($this->aObjectsIDs))); + $sMessage = Dict::S('ExcelExporter:RetrievingData'); + } + break; + + case 'building-excel': + $hFile = @fopen($this->GetDataFile(), 'rb'); + if ($hFile === false) + { + throw new Exception('ExcelExporter: Failed to open temporary data file: "'.$this->GetDataFile().'" for reading.'); + } + $sHeaders = fgets($hFile); + $aHeaders = json_decode($sHeaders, true); + + $aData = array(); + while($sLine = fgets($hFile)) + { + $aRow = json_decode($sLine); + $aData[] = $aRow; + } + fclose($hFile); + @unlink($this->GetDataFile()); + + $fStartExcel = microtime(true); + $writer = new XLSXWriter(); + $writer->setAuthor(UserRights::GetUserFriendlyName()); + $writer->writeSheet($aData,'Sheet1', $aHeaders); + $fExcelTime = microtime(true) - $fStartExcel; + $this->aStatistics['excel_build_duration'] = $fExcelTime; + + $fTime = microtime(true); + $writer->writeToFile($this->GetExcelFilePath()); + $fExcelSaveTime = microtime(true) - $fTime; + $this->aStatistics['excel_write_duration'] = $fExcelSaveTime; + + // Next state + $this->sState = 'done'; + $sCode = 'done'; + $iPercentage = 100; + $sMessage = Dict::S('ExcelExporter:Done'); + break; + + case 'done': + $this->sState = 'done'; + $sCode = 'done'; + $iPercentage = 100; + $sMessage = Dict::S('ExcelExporter:Done'); + break; + } + } + catch(Exception $e) + { + $sCode = 'error'; + $sMessage = $e->getMessage(); + } + + $this->aStatistics['total_duration'] += microtime(true) - $fTime; + $peak_memory = memory_get_peak_usage(true); + if ($peak_memory > $this->aStatistics['peak_memory_usage']) + { + $this->aStatistics['peak_memory_usage'] = $peak_memory; + } + + return array( + 'code' => $sCode, + 'message' => $sMessage, + 'percentage' => $iPercentage, + ); + } + + public function GetExcelFilePath() + { + if ($this->sOutputFilePath == null) + { + return APPROOT.'data/bulk_export/'.$this->sToken.'.xlsx'; + } + else + { + return $this->sOutputFilePath; + } + } + + public static function GetExcelFileFromToken($sToken) + { + return @file_get_contents(APPROOT.'data/bulk_export/'.$sToken.'.xlsx'); + } + + public static function CleanupFromToken($sToken) + { + @unlink(APPROOT.'data/bulk_export/'.$sToken.'.status'); + @unlink(APPROOT.'data/bulk_export/'.$sToken.'.data'); + @unlink(APPROOT.'data/bulk_export/'.$sToken.'.xlsx'); + } + + public function Cleanup() + { + self::CleanupFromToken($this->sToken); + } + + /** + * Delete all files in the data/bulk_export directory which are older than 1 day + * unless a different delay is configured. + */ + public static function CleanupOldFiles() + { + $aFiles = glob(APPROOT.'data/bulk_export/*.*'); + $iDelay = MetaModel::GetConfig()->Get('xlsx_exporter_cleanup_old_files_delay'); + + if($iDelay > 0) + { + foreach($aFiles as $sFile) + { + $iModificationTime = filemtime($sFile); + + if($iModificationTime < (time() - $iDelay)) + { + // Temporary files older than one day are deleted + //echo "Supposed to delete: '".$sFile." (Unix Modification Time: $iModificationTime)'\n"; + @unlink($sFile); + } + } + } + } + + public function DisplayStatistics(Page $oPage) + { + $aStats = array( + 'Number of objects exported' => $this->aStatistics['objects_count'], + 'Total export duration' => sprintf('%.3f s', $this->aStatistics['total_duration']), + 'Data retrieval duration' => sprintf('%.3f s', $this->aStatistics['data_retrieval_duration']), + 'Excel build duration' => sprintf('%.3f s', $this->aStatistics['excel_build_duration']), + 'Excel write duration' => sprintf('%.3f s', $this->aStatistics['excel_write_duration']), + 'Peak memory usage' => self::HumanDisplay($this->aStatistics['peak_memory_usage']), + ); + + if ($oPage instanceof CLIPage) + { + $oPage->add($this->GetStatistics('text')); + } + else + { + $oPage->add($this->GetStatistics('html')); + } + } + + public function GetStatistics($sFormat = 'html') + { + $sStats = ''; + $aStats = array( + 'Number of objects exported' => $this->aStatistics['objects_count'], + 'Total export duration' => sprintf('%.3f s', $this->aStatistics['total_duration']), + 'Data retrieval duration' => sprintf('%.3f s', $this->aStatistics['data_retrieval_duration']), + 'Excel build duration' => sprintf('%.3f s', $this->aStatistics['excel_build_duration']), + 'Excel write duration' => sprintf('%.3f s', $this->aStatistics['excel_write_duration']), + 'Peak memory usage' => self::HumanDisplay($this->aStatistics['peak_memory_usage']), + ); + + if ($sFormat == 'text') + { + foreach($aStats as $sLabel => $sValue) + { + $sStats .= "+------------------------------+----------+\n"; + $sStats .= sprintf("|%-30s|%10s|\n", $sLabel, $sValue); + } + $sStats .= "+------------------------------+----------+"; + } + else + { + $sStats .= ''; + foreach($aStats as $sLabel => $sValue) + { + $sStats .= ""; + } + $sStats .= '
$sLabel$sValue
'; + + } + return $sStats; + } + + public static function HumanDisplay($iSize) + { + $aUnits = array('B','KB','MB','GB','TB','PB'); + return @round($iSize/pow(1024,($i=floor(log($iSize,1024)))),2).' '.$aUnits[$i]; + } + + protected function CheckDataDir() + { + if(!is_dir(APPROOT."data/bulk_export")) + { + @mkdir(APPROOT."data/bulk_export", 0777, true /* recursive */); + clearstatcache(); + } + if (!is_writable(APPROOT."data/bulk_export")) + { + throw new Exception('Data directory "'.APPROOT.'data/bulk_export" could not be written.'); + } + } + + protected function GetStateFile($sToken = null) + { + if ($sToken == null) + { + $sToken = $this->sToken; + } + return APPROOT."data/bulk_export/$sToken.status"; + } + + protected function GetDataFile() + { + return APPROOT.'data/bulk_export/'.$this->sToken.'.data'; + } + + protected function GetNewToken() + { + $iNum = rand(); + do + { + $iNum++; + $sToken = sprintf("%08x", $iNum); + $sFileName = $this->GetStateFile($sToken); + $hFile = @fopen($sFileName, 'x'); + } + while($hFile === false); + + fclose($hFile); + return $sToken; + } + + protected function GetFieldsList($oSet, $bFieldsAdvanced = false, $bLocalize = true, $aFields = null) + { + $this->aFieldsList = array(); + + $oAppContext = new ApplicationContext(); + $aClasses = $oSet->GetFilter()->GetSelectedClasses(); + $this->aAuthorizedClasses = array(); + foreach($aClasses as $sAlias => $sClassName) + { + if (UserRights::IsActionAllowed($sClassName, UR_ACTION_READ, $oSet) && (UR_ALLOWED_YES || UR_ALLOWED_DEPENDS)) + { + $this->aAuthorizedClasses[$sAlias] = $sClassName; + } + } + $aAttribs = array(); + $this->aTableHeaders = array(); + foreach($this->aAuthorizedClasses as $sAlias => $sClassName) + { + $aList[$sAlias] = array(); + + foreach(MetaModel::ListAttributeDefs($sClassName) as $sAttCode => $oAttDef) + { + if (is_null($aFields) || (count($aFields) == 0)) + { + // Standard list of attributes (no link sets) + if ($oAttDef->IsScalar() && ($oAttDef->IsWritable() || $oAttDef->IsExternalField())) + { + $sAttCodeEx = $oAttDef->IsExternalField() ? $oAttDef->GetKeyAttCode().'->'.$oAttDef->GetExtAttCode() : $sAttCode; + + if ($oAttDef->IsExternalKey(EXTKEY_ABSOLUTE)) + { + if ($bFieldsAdvanced) + { + $aList[$sAlias][$sAttCodeEx] = $oAttDef; + + if ($oAttDef->IsExternalKey(EXTKEY_RELATIVE)) + { + $sRemoteClass = $oAttDef->GetTargetClass(); + foreach(MetaModel::GetReconcKeys($sRemoteClass) as $sRemoteAttCode) + { + $this->aFieldsList[$sAlias][$sAttCode.'->'.$sRemoteAttCode] = MetaModel::GetAttributeDef($sRemoteClass, $sRemoteAttCode); + } + } + } + } + else + { + // Any other attribute + $this->aFieldsList[$sAlias][$sAttCodeEx] = $oAttDef; + } + } + } + else + { + // User defined list of attributes + if (in_array($sAttCode, $aFields) || in_array($sAlias.'.'.$sAttCode, $aFields)) + { + $this->aFieldsList[$sAlias][$sAttCode] = $oAttDef; + } + } + } + if ($bFieldsAdvanced) + { + $this->aTableHeaders['id'] = '0'; + } + foreach($this->aFieldsList[$sAlias] as $sAttCodeEx => $oAttDef) + { + $sLabel = $bLocalize ? MetaModel::GetLabel($sClassName, $sAttCodeEx, isset($aParams['showMandatoryFields'])) : $sAttCodeEx; + if($oAttDef instanceof AttributeDateTime) + { + $this->aTableHeaders[$sLabel] = 'datetime'; + } + else + { + $this->aTableHeaders[$sLabel] = 'string'; + } + } + } + } +} + diff --git a/application/utils.inc.php b/application/utils.inc.php index fd27b4b1c..9a5521acc 100644 --- a/application/utils.inc.php +++ b/application/utils.inc.php @@ -783,11 +783,16 @@ class utils $sOQL = addslashes($param->GetFilter()->ToOQL(true)); $sFilter = urlencode($param->GetFilter()->serialize()); $sUrl = utils::GetAbsoluteUrlAppRoot()."pages/$sUIPage?operation=search&filter=".$sFilter."&{$sContext}"; + $oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/xlsx-export.js'); + $sXlsxFilter = $param->GetFilter()->serialize(); + $sXlsxJSFilter = addslashes($sXlsxFilter); + $aResult = array( new SeparatorPopupMenuItem(), // Static menus: Email this page, CSV Export & Add to Dashboard new URLPopupMenuItem('UI:Menu:EMail', Dict::S('UI:Menu:EMail'), "mailto:?body=".urlencode($sUrl).' '), // Add an extra space to make it work in Outlook new URLPopupMenuItem('UI:Menu:CSVExport', Dict::S('UI:Menu:CSVExport'), $sUrl."&format=csv"), + new JSPopupMenuItem('xlsx-export', Dict::S('ExcelExporter:ExportMenu'), "XlsxExportDialog('$sXlsxJSFilter');", array()), new JSPopupMenuItem('UI:Menu:AddToDashboard', Dict::S('UI:Menu:AddToDashboard'), "DashletCreationDlg('$sOQL')"), new JSPopupMenuItem('UI:Menu:ShortcutList', Dict::S('UI:Menu:ShortcutList'), "ShortcutListDlg('$sOQL', '$sDataTableId', '$sContext')"), ); @@ -802,11 +807,15 @@ class utils $sUIPage = cmdbAbstractObject::ComputeStandardUIPage(get_class($oObj)); $oAppContext = new ApplicationContext(); $sContext = $oAppContext->GetForLink(); + $oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/xlsx-export.js'); + $sXlsxFilter = $param->GetFilter()->serialize(); + $sXlsxJSFilter = addslashes($sXlsxFilter); $aResult = array( new SeparatorPopupMenuItem(), // Static menus: Email this page & CSV Export new URLPopupMenuItem('UI:Menu:EMail', Dict::S('UI:Menu:EMail'), "mailto:?subject=".urlencode($oObj->GetRawName())."&body=".urlencode($sUrl).' '), // Add an extra space to make it work in Outlook new URLPopupMenuItem('UI:Menu:CSVExport', Dict::S('UI:Menu:CSVExport'), utils::GetAbsoluteUrlAppRoot()."pages/$sUIPage?operation=search&filter=".urlencode($sFilter)."&format=csv&{$sContext}"), + new JSPopupMenuItem('xlsx-export', Dict::S('ExcelExporter:ExportMenu'), "XlsxExportDialog('$sXlsxJSFilter');", array()), ); break; diff --git a/application/xlsxwriter.class.php b/application/xlsxwriter.class.php new file mode 100644 index 000000000..8d42980d7 --- /dev/null +++ b/application/xlsxwriter.class.php @@ -0,0 +1,456 @@ +author=$author; } + + public function __destruct() + { + if (!empty($this->temp_files)) { + foreach($this->temp_files as $temp_file) { + @unlink($temp_file); + } + } + } + + protected function tempFilename() + { + $filename = tempnam("/tmp", "xlsx_writer_"); + $this->temp_files[] = $filename; + return $filename; + } + + public function writeToStdOut() + { + $temp_file = $this->tempFilename(); + self::writeToFile($temp_file); + readfile($temp_file); + } + + public function writeToString() + { + $temp_file = $this->tempFilename(); + self::writeToFile($temp_file); + $string = file_get_contents($temp_file); + return $string; + } + + public function writeToFile($filename) + { + @unlink($filename);//if the zip already exists, overwrite it + $zip = new ZipArchive(); + if (empty($this->sheets_meta)) { self::log("Error in ".__CLASS__."::".__FUNCTION__.", no worksheets defined."); return; } + if (!$zip->open($filename, ZipArchive::CREATE)) { self::log("Error in ".__CLASS__."::".__FUNCTION__.", unable to create zip."); return; } + + $zip->addEmptyDir("docProps/"); + $zip->addFromString("docProps/app.xml" , self::buildAppXML() ); + $zip->addFromString("docProps/core.xml", self::buildCoreXML()); + + $zip->addEmptyDir("_rels/"); + $zip->addFromString("_rels/.rels", self::buildRelationshipsXML()); + + $zip->addEmptyDir("xl/worksheets/"); + foreach($this->sheets_meta as $sheet_meta) { + $zip->addFile($sheet_meta['filename'], "xl/worksheets/".$sheet_meta['xmlname'] ); + } + if (!empty($this->shared_strings)) { + $zip->addFile($this->writeSharedStringsXML(), "xl/sharedStrings.xml" ); //$zip->addFromString("xl/sharedStrings.xml", self::buildSharedStringsXML() ); + } + $zip->addFromString("xl/workbook.xml" , self::buildWorkbookXML() ); + $zip->addFile($this->writeStylesXML(), "xl/styles.xml" ); //$zip->addFromString("xl/styles.xml" , self::buildStylesXML() ); + $zip->addFromString("[Content_Types].xml" , self::buildContentTypesXML() ); + + $zip->addEmptyDir("xl/_rels/"); + $zip->addFromString("xl/_rels/workbook.xml.rels", self::buildWorkbookRelsXML() ); + $zip->close(); + } + + + public function writeSheet(array $data, $sheet_name='', array $header_types=array() ) + { + $data = empty($data) ? array( array('') ) : $data; + + $sheet_filename = $this->tempFilename(); + $sheet_default = 'Sheet'.(count($this->sheets_meta)+1); + $sheet_name = !empty($sheet_name) ? $sheet_name : $sheet_default; + $this->sheets_meta[] = array('filename'=>$sheet_filename, 'sheetname'=>$sheet_name ,'xmlname'=>strtolower($sheet_default).".xml" ); + + $header_offset = empty($header_types) ? 0 : 1; + $row_count = count($data) + $header_offset; + $column_count = count($data[self::array_first_key($data)]); + $max_cell = self::xlsCell( $row_count-1, $column_count-1 ); + + $tabselected = count($this->sheets_meta)==1 ? 'true' : 'false';//only first sheet is selected + $cell_formats_arr = empty($header_types) ? array_fill(0, $column_count, 'string') : array_values($header_types); + $header_row = empty($header_types) ? array() : array_keys($header_types); + + $fd = fopen($sheet_filename, "w+"); + if ($fd===false) { self::log("write failed in ".__CLASS__."::".__FUNCTION__."."); return; } + + fwrite($fd,''."\n"); + fwrite($fd,''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + if (!empty($header_row)) + { + fwrite($fd, ''); + } + foreach($data as $i=>$row) + { + fwrite($fd, ''); + } + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, '&C&"Times New Roman,Regular"&12&A'); + fwrite($fd, '&C&"Times New Roman,Regular"&12Page &P'); + fwrite($fd, ''); + fwrite($fd,''); + fclose($fd); + } + + protected function writeCell($fd, $row_number, $column_number, $value, $cell_format) + { + static $styles = array('money'=>1,'dollar'=>1,'datetime'=>2,'date'=>3,'string'=>0); + $cell = self::xlsCell($row_number, $column_number); + $s = isset($styles[$cell_format]) ? $styles[$cell_format] : '0'; + + if (is_numeric($value)) { + fwrite($fd,''.($value*1).'');//int,float, etc + } else if ($cell_format=='date') { + fwrite($fd,''.intval(self::convert_date_time($value)).''); + } else if ($cell_format=='datetime') { + if ($value === '') { + fwrite($fd,''); + } else { + fwrite($fd,''.self::convert_date_time($value).''); + } + } else if ($value==''){ + fwrite($fd,''); + } else if ($value{0}=='='){ + fwrite($fd,''.self::xmlspecialchars($value).''); + } else if ($value!==''){ + fwrite($fd,''.self::xmlspecialchars($this->setSharedString($value)).''); + } + } + + protected function writeStylesXML() + { + $tempfile = $this->tempFilename(); + $fd = fopen($tempfile, "w+"); + if ($fd===false) { self::log("write failed in ".__CLASS__."::".__FUNCTION__."."); return; } + fwrite($fd, ''."\n"); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + //fwrite($fd, ''); + //fwrite($fd, ''); + //fwrite($fd, ''); + //fwrite($fd, ''); + //fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + //fwrite($fd, ''); + //fwrite($fd, ''); + //fwrite($fd, ''); + //fwrite($fd, ''); + //fwrite($fd, ''); + fwrite($fd, ''); + fwrite($fd, ''); + fclose($fd); + return $tempfile; + } + + protected function setSharedString($v) + { + // Strip control characters which Excel does not seem to like... + $v = preg_replace('/[\x00-\x09\x0B\x0C\x0E-\x1F]/u', '', $v); + if (isset($this->shared_strings[$v])) + { + $string_value = $this->shared_strings[$v]; + } + else + { + $string_value = count($this->shared_strings); + $this->shared_strings[$v] = $string_value; + } + $this->shared_string_count++;//non-unique count + return $string_value; + } + + protected function writeSharedStringsXML() + { + $tempfile = $this->tempFilename(); + $fd = fopen($tempfile, "w+"); + if ($fd===false) { self::log("write failed in ".__CLASS__."::".__FUNCTION__."."); return; } + + fwrite($fd,''."\n"); + fwrite($fd,''); + foreach($this->shared_strings as $s=>$c) + { + fwrite($fd,''.self::xmlspecialchars($s).''); + } + fwrite($fd, ''); + fclose($fd); + return $tempfile; + } + + protected function buildAppXML() + { + $app_xml=""; + $app_xml.=''."\n"; + $app_xml.='0'; + return $app_xml; + } + + protected function buildCoreXML() + { + $core_xml=""; + $core_xml.=''."\n"; + $core_xml.=''; + $core_xml.=''.date("Y-m-d\TH:i:s.00\Z").'';//$date_time = '2013-07-25T15:54:37.00Z'; + $core_xml.=''.self::xmlspecialchars($this->author).''; + $core_xml.='0'; + $core_xml.=''; + return $core_xml; + } + + protected function buildRelationshipsXML() + { + $rels_xml=""; + $rels_xml.=''."\n"; + $rels_xml.=''; + $rels_xml.=''; + $rels_xml.=''; + $rels_xml.=''; + $rels_xml.="\n"; + $rels_xml.=''; + return $rels_xml; + } + + protected function buildWorkbookXML() + { + $workbook_xml=""; + $workbook_xml.=''."\n"; + $workbook_xml.=''; + $workbook_xml.=''; + $workbook_xml.=''; + $workbook_xml.=''; + foreach($this->sheets_meta as $i=>$sheet_meta) { + $workbook_xml.=''; + } + $workbook_xml.=''; + $workbook_xml.=''; + return $workbook_xml; + } + + protected function buildWorkbookRelsXML() + { + $wkbkrels_xml=""; + $wkbkrels_xml.=''."\n"; + $wkbkrels_xml.=''; + $wkbkrels_xml.=''; + foreach($this->sheets_meta as $i=>$sheet_meta) { + $wkbkrels_xml.=''; + } + if (!empty($this->shared_strings)) { + $wkbkrels_xml.=''; + } + $wkbkrels_xml.="\n"; + $wkbkrels_xml.=''; + return $wkbkrels_xml; + } + + protected function buildContentTypesXML() + { + $content_types_xml=""; + $content_types_xml.=''."\n"; + $content_types_xml.=''; + $content_types_xml.=''; + $content_types_xml.=''; + foreach($this->sheets_meta as $i=>$sheet_meta) { + $content_types_xml.=''; + } + if (!empty($this->shared_strings)) { + $content_types_xml.=''; + } + $content_types_xml.=''; + $content_types_xml.=''; + $content_types_xml.=''; + $content_types_xml.=''; + $content_types_xml.="\n"; + $content_types_xml.=''; + return $content_types_xml; + } + + //------------------------------------------------------------------ + /* + * @param $row_number int, zero based + * @param $column_number int, zero based + * @return Cell label/coordinates, ex: A1, C3, AA42 + * */ + public static function xlsCell($row_number, $column_number) + { + $n = $column_number; + for($r = ""; $n >= 0; $n = intval($n / 26) - 1) { + $r = chr($n%26 + 0x41) . $r; + } + return $r . ($row_number+1); + } + //------------------------------------------------------------------ + public static function log($string) + { + file_put_contents("php://stderr", date("Y-m-d H:i:s:").rtrim(is_array($string) ? json_encode($string) : $string)."\n"); + } + //------------------------------------------------------------------ + public static function xmlspecialchars($val) + { + return str_replace("'", "'", htmlspecialchars($val)); + } + //------------------------------------------------------------------ + public static function array_first_key(array $arr) + { + reset($arr); + $first_key = key($arr); + return $first_key; + } + //------------------------------------------------------------------ + public static function convert_date_time($date_input) //thanks to Excel::Writer::XLSX::Worksheet.pm (perl) + { + $days = 0; # Number of days since epoch + $seconds = 0; # Time expressed as fraction of 24h hours in seconds + $year=$month=$day=0; + $hour=$min =$sec=0; + + $date_time = $date_input; + if (preg_match("/(\d{4})\-(\d{2})\-(\d{2})/", $date_time, $matches)) + { + list($junk,$year,$month,$day) = $matches; + } + if (preg_match("/(\d{2}):(\d{2}):(\d{2})/", $date_time, $matches)) + { + list($junk,$hour,$min,$sec) = $matches; + $seconds = ( $hour * 60 * 60 + $min * 60 + $sec ) / ( 24 * 60 * 60 ); + } + + //using 1900 as epoch, not 1904, ignoring 1904 special case + + # Special cases for Excel. + if ("$year-$month-$day"=='1899-12-31') return $seconds ; # Excel 1900 epoch + if ("$year-$month-$day"=='1900-01-00') return $seconds ; # Excel 1900 epoch + if ("$year-$month-$day"=='1900-02-29') return 60 + $seconds ; # Excel false leapday + + # We calculate the date by calculating the number of days since the epoch + # and adjust for the number of leap days. We calculate the number of leap + # days by normalising the year in relation to the epoch. Thus the year 2000 + # becomes 100 for 4 and 100 year leapdays and 400 for 400 year leapdays. + $epoch = 1900; + $offset = 0; + $norm = 300; + $range = $year - $epoch; + + # Set month days and check for leap year. + $leap = (($year % 400 == 0) || (($year % 4 == 0) && ($year % 100)) ) ? 1 : 0; + $mdays = array( 31, ($leap ? 29 : 28), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ); + + # Some boundary checks + if($year < $epoch || $year > 9999) return 0; + if($month < 1 || $month > 12) return 0; + if($day < 1 || $day > $mdays[ $month - 1 ]) return 0; + + # Accumulate the number of days since the epoch. + $days = $day; # Add days for current month + $days += array_sum( array_slice($mdays, 0, $month-1 ) ); # Add days for past months + $days += $range * 365; # Add days for past years + $days += intval( ( $range ) / 4 ); # Add leapdays + $days -= intval( ( $range + $offset ) / 100 ); # Subtract 100 year leapdays + $days += intval( ( $range + $offset + $norm ) / 400 ); # Add 400 year leapdays + $days -= $leap; # Already counted above + + # Adjust for Excel erroneously treating 1900 as a leap year. + if ($days > 59) { $days++;} + + return $days + $seconds; + } + //------------------------------------------------------------------ +} + + + + + + diff --git a/core/config.class.inc.php b/core/config.class.inc.php index fcfa44c90..b2126de8f 100644 --- a/core/config.class.inc.php +++ b/core/config.class.inc.php @@ -777,6 +777,22 @@ class Config 'source_of_value' => '', 'show_in_conf_sample' => false, ), + 'xlsx_exporter_cleanup_old_files_delay' => array( + 'type' => 'int', + 'description' => 'Delay (in seconds) for which to let the exported XLSX files on the server so that the user who initiated the export can download the result', + 'default' => 86400, + 'value' => '', + 'source_of_value' => '', + 'show_in_conf_sample' => false, + ), + 'xlsx_exporter_memory_limit' => array( + 'type' => 'string', + 'description' => 'Memory limit to use when (interactively) exporting data to Excel', + 'default' => '2048M', // Huuuuuuge 2GB! + 'value' => '', + 'source_of_value' => '', + 'show_in_conf_sample' => false, + ), ); public function IsProperty($sPropCode) diff --git a/dictionaries/dictionary.itop.ui.php b/dictionaries/dictionary.itop.ui.php index 4aee9f27a..09c32ffe0 100644 --- a/dictionaries/dictionary.itop.ui.php +++ b/dictionaries/dictionary.itop.ui.php @@ -1217,5 +1217,16 @@ When associated with a trigger, each action is given an "order" number, specifyi 'UI:About:Support' => 'Support information', 'UI:About:Licenses' => 'Licenses', 'UI:About:Modules' => 'Installed modules', + + 'ExcelExporter:ExportMenu' => 'Excel Export...', + 'ExcelExporter:ExportDialogTitle' => 'Excel Export', + 'ExcelExporter:ExportButton' => 'Export', + 'ExcelExporter:DownloadButton' => 'Download %1$s', + 'ExcelExporter:RetrievingData' => 'Retrieving data...', + 'ExcelExporter:BuildingExcelFile' => 'Building the Excel file...', + 'ExcelExporter:Done' => 'Done.', + 'ExcelExport:AutoDownload' => 'Start the download automatically when the export is ready', + 'ExcelExport:PreparingExport' => 'Preparing the export...', + 'ExcelExport:Statistics' => 'Statistics', )); ?> diff --git a/dictionaries/fr.dictionary.itop.ui.php b/dictionaries/fr.dictionary.itop.ui.php index 409305d34..9f1db87bf 100644 --- a/dictionaries/fr.dictionary.itop.ui.php +++ b/dictionaries/fr.dictionary.itop.ui.php @@ -1057,5 +1057,16 @@ Lors de l\'association à un déclencheur, on attribue à chaque action un numé 'UI:About:Support' => 'Informations pour le support', 'UI:About:Licenses' => 'Licences', 'UI:About:Modules' => 'Modules installés', + + 'ExcelExporter:ExportMenu' => 'Exporter pour Excel...', + 'ExcelExporter:ExportDialogTitle' => 'Export au format Excel', + 'ExcelExporter:ExportButton' => 'Exporter', + 'ExcelExporter:DownloadButton' => 'Télécharger %1$s', + 'ExcelExporter:RetrievingData' => 'Récupération des données...', + 'ExcelExporter:BuildingExcelFile' => 'Construction du fichier Excel...', + 'ExcelExporter:Done' => 'Terminé.', + 'ExcelExport:AutoDownload' => 'Téléchargement automatique dès que le fichier est prêt', + 'ExcelExport:PreparingExport' => 'Préparation de l\'export...', + 'ExcelExport:Statistics' => 'Statistiques', )); ?> diff --git a/images/xlsx.png b/images/xlsx.png new file mode 100644 index 000000000..ea8193b3f Binary files /dev/null and b/images/xlsx.png differ diff --git a/js/xlsx-export.js b/js/xlsx-export.js new file mode 100644 index 000000000..e6d99a2f7 --- /dev/null +++ b/js/xlsx-export.js @@ -0,0 +1,185 @@ +// jQuery UI style "widget" for managing the "xlsx-exporter" +$(function() +{ + // the widget definition, where "itop" is the namespace, + // "xlsxexporter" the widget name + $.widget( "itop.xlsxexporter", + { + // default options + options: + { + filter: '', + ajax_page_url: '', + labels: {dialog_title: 'Excel Export', export_button: 'Export', cancel_button: 'Cancel', download_button: 'Download', complete: 'Complete', cancelled: 'Cancelled' } + }, + + // the constructor + _create: function() + { + this.element + .addClass('itop-xlsxexporter'); + + this.sToken = null; + this.ajaxCall = null; + this.oProgressBar = $('.progress-bar', this.element); + this.oStatusMessage = $('.status-message', this.element); + $('.progress', this.element).hide(); + $('.statistics', this.element).hide(); + + var me = this; + + this.element.dialog({ + title: this.options.labels.dialog_title, + modal: true, + width: 500, + height: 300, + buttons: [ + { text: this.options.labels.export_button, 'class': 'export-button', click: function() { + me._start(); + } }, + { text: this.options.labels.cancel_button, 'class': 'cancel-button', click: function() { + $(this).dialog( "close" ); + } }, + ], + close: function() { me._abort(); $(this).remove(); } + }); + }, + // events bound via _bind are removed automatically + // revert other modifications here + destroy: function() + { + this.element + .removeClass('itop-xlsxexporter'); + }, + // _setOptions is called with a hash of all options that are changing + _setOptions: function() + { + this._superApply(arguments); + }, + // _setOption is called for each individual option that is changing + _setOption: function( key, value ) + { + this._superApply(arguments); + }, + _start: function() + { + var me = this; + $('.export-options', this.element).hide(); + $('.progress', this.element).show(); + var bAdvanced = $('#export-advanced-mode').prop('checked'); + this.bAutoDownload = $('#export-auto-download').prop('checked'); + $('.export-button', this.element.parent()).button('disable'); + + this.oProgressBar.progressbar({ + value: 0, + change: function() { + var progressLabel = $('.progress-label', me.element); + progressLabel.text( $(this).progressbar( "value" ) + "%" ); + }, + complete: function() { + var progressLabel = $('.progress-label', me.element); + progressLabel.text( me.options.labels['complete'] ); + } + }); + + //TODO disable the "export" button + this.ajaxCall = $.post(this.options.ajax_page_url, {filter: this.options.filter, operation: 'xlsx_start', advanced: bAdvanced}, function(data) { + this.ajaxCall = null; + if (data && data.status == 'ok') + { + me.sToken = data.token; + me._run(); + } + else + { + if (data == null) + { + me.oStatusMessage.html('Unexpected error (operation=xlsx_start).'); + me.oProgressBar.progressbar({value: 100}); + } + else + { + me.oStatusMessage.html(data.message); + } + } + }, 'json'); + + }, + _abort: function() + { + $('.cancel-button', this.element.parent()).button('disable'); + this.oStatusMessage.html(this.options.labels['cancelled']); + this.oProgressBar.progressbar({value: 100}); + if (this.sToken != null) + { + // Cancel the operation in progress... or cleanup a completed export + // TODO + if (this.ajaxCall) + { + this.ajaxCall.abort(); + this.ajaxClass = null; + } + var me = this; + $.post(this.options.ajax_page_url, {token: this.sToken, operation: 'xlsx_abort'}, function(data) { + me.sToken = null; + }); + } + }, + _run: function() + { + var me = this; + this.ajaxCall = $.post(this.options.ajax_page_url, {token: this.sToken, operation: 'xlsx_run'}, function(data) { + this.ajaxCall = null; + if (data == null) + { + me.oStatusMessage.html('Unexpected error (operation=xlsx_run).'); + me.oProgressBar.progressbar({value: 100}); + } + else if (data.status == 'error') + { + me.oStatusMessage.html(data.message); + me.oProgressBar.progressbar({value: 100}); + } + else if (data.status == 'done') + { + me.oStatusMessage.html(data.message); + me.oProgressBar.progressbar({value: 100}); + $('.stats-data', this.element).html(data.statistics); + me._on_completion(); + } + else + { + // continue running the export in the background + me.oStatusMessage.html(data.message); + me.oProgressBar.progressbar({value: data.percentage}); + me._run(); + } + }, 'json'); + }, + _on_completion: function() + { + var me = this; + $('.progress', this.element).html('
'); + $('.download-form button', this.element).button().click(function() { me.sToken = null; window.setTimeout(function() { me.element.dialog('close'); }, 100); return true;}); + if (this.bAutoDownload) + { + me.sToken = null; + $('.download-form').submit(); + this.element.dialog('close'); + } + else + { + $('.statistics', this.element).show(); + $('.statistics .stats-toggle', this.element).click(function() { $(this).toggleClass('closed'); }); + } + } + }); +}); + +function XlsxExportDialog(sFilter) +{ + var sUrl = GetAbsoluteUrlAppRoot()+'pages/ajax.render.php'; + $.post(sUrl, {operation: 'xlsx_export_dialog', filter: sFilter}, function(data) { + $('body').append(data); + }); +} diff --git a/pages/ajax.csvimport.php b/pages/ajax.csvimport.php index cecc8265f..124f9c44e 100644 --- a/pages/ajax.csvimport.php +++ b/pages/ajax.csvimport.php @@ -418,6 +418,7 @@ EOF case 'get_csv_template': $sClassName = utils::ReadParam('class_name'); + $sFormat = utils::ReadParam('format', 'csv'); if (MetaModel::IsValidClass($sClassName)) { $oSearch = new DBObjectSearch($sClassName); @@ -429,17 +430,37 @@ EOF $sDisposition = utils::ReadParam('disposition', 'inline'); if ($sDisposition == 'attachment') { - $oPage = new CSVPage(""); - $oPage->add_header("Content-type: text/csv; charset=utf-8"); - $oPage->add_header("Content-disposition: attachment; filename=\"{$sClassDisplayName}.csv\""); - $oPage->no_cache(); - $oPage->add($sResult); + switch($sFormat) + { + case 'xlsx': + $oPage = new ajax_page(""); + $oPage->SetContentType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $oPage->SetContentDisposition('attachment', $sClassDisplayName.'.xlsx'); + require_once(APPROOT.'/application/excelexporter.class.inc.php'); + $writer = new XLSXWriter(); + $writer->setAuthor(UserRights::GetUserFriendlyName()); + $aHeaders = array( 0 => explode(',', $sResult)); // comma is the default separator + $writer->writeSheet($aHeaders, $sClassDisplayName, array()); + $oPage->add($writer->writeToString()); + break; + + case 'csv': + default: + $oPage = new CSVPage(""); + $oPage->add_header("Content-type: text/csv; charset=utf-8"); + $oPage->add_header("Content-disposition: attachment; filename=\"{$sClassDisplayName}.csv\""); + $oPage->no_cache(); + $oPage->add($sResult); + } } else { $oPage = new ajax_page(""); $oPage->no_cache(); - $oPage->add('


'.$sClassDisplayName.'.csv

'); + $oPage->add('

'); + $oPage->add('


'.$sClassDisplayName.'.csv
'); + $oPage->add('

'.$sClassDisplayName.'.xlsx
'); + $oPage->add('

'); $oPage->add('

'); } } diff --git a/pages/ajax.render.php b/pages/ajax.render.php index 0fb54cb83..917456135 100644 --- a/pages/ajax.render.php +++ b/pages/ajax.render.php @@ -32,6 +32,7 @@ require_once(APPROOT.'/application/wizardhelper.class.inc.php'); require_once(APPROOT.'/application/ui.linkswidget.class.inc.php'); require_once(APPROOT.'/application/ui.extkeywidget.class.inc.php'); require_once(APPROOT.'/application/datatable.class.inc.php'); +require_once(APPROOT.'/application/excelexporter.class.inc.php'); try { @@ -1608,6 +1609,119 @@ EOF ); break; + case 'xlsx_export_dialog': + $sFilter = utils::ReadParam('filter', '', false, 'raw_data'); + $oPage->SetContentType('text/html'); + $oPage->add( +<< + .ui-progressbar { + position: relative; +} +.progress-label { + position: absolute; + left: 50%; + top: 1px; + font-size: 11pt; +} +.download-form button { + display:block; + margin-left: auto; + margin-right: auto; + margin-top: 2em; +} +.ui-progressbar-value { + background: url(../setup/orange-progress.gif); +} +.progress-bar { + height: 20px; +} +.statistics > div { + padding-left: 16px; + cursor: pointer; + font-size: 10pt; + background: url(../images/minus.gif) 0 2px no-repeat; +} +.statistics > div.closed { + padding-left: 16px; + background: url(../images/plus.gif) 0 2px no-repeat; +} + +.statistics .closed .stats-data { + display: none; +} +.stats-data td { + padding-right: 5px; +} + +EOF + ); + $oPage->add('
'); + $oPage->add('
'); + $oPage->add('

 

'); + $oPage->add('

'.Dict::S('UI:CSVImport:AdvancedMode+').'

'); + $oPage->add('

 

'); + $oPage->add('
'); + $oPage->add('

'.Dict::S('ExcelExport:PreparingExport').'

'); + $oPage->add('
'.Dict::S('ExcelExport:Statistics').'
'); + $oPage->add('
'); + $aLabels = array( + 'dialog_title' => Dict::S('ExcelExporter:ExportDialogTitle'), + 'cancel_button' => Dict::S('UI:Button:Cancel'), + 'export_button' => Dict::S('ExcelExporter:ExportButton'), + 'download_button' => Dict::Format('ExcelExporter:DownloadButton', 'export.xlsx'), //TODO: better name for the file (based on the class of the filter??) + ); + $sJSLabels = json_encode($aLabels); + $sFilter = addslashes($sFilter); + $sJSPageUrl = addslashes(utils::GetAbsoluteUrlAppRoot().'pages/ajax.render.php'); + $oPage->add_ready_script("$('#XlsxExportDlg').xlsxexporter({filter: '$sFilter', labels: $sJSLabels, ajax_page_url: '$sJSPageUrl'});"); + break; + + case 'xlsx_start': + $sFilter = utils::ReadParam('filter', '', false, 'raw_data'); + $bAdvanced = (utils::ReadParam('advanced', 'false') == 'true'); + $oSearch = DBObjectSearch::unserialize($sFilter); + + $oExcelExporter = new ExcelExporter(); + $oExcelExporter->SetObjectList($oSearch); + //$oExcelExporter->SetChunkSize(10); //Only for testing + $oExcelExporter->SetAdvancedMode($bAdvanced); + $sToken = $oExcelExporter->SaveState(); + $oPage->add(json_encode(array('status' => 'ok', 'token' => $sToken))); + break; + + case 'xlsx_run': + $sMemoryLimit = MetaModel::GetConfig()->Get('xlsx_exporter_memory_limit'); + ini_set('memory_limit', $sMemoryLimit); + ini_set('max_execution_time', max(300, ini_get('max_execution_time'))); // At least 5 minutes + + $sToken = utils::ReadParam('token', '', false, 'raw_data'); + $oExcelExporter = new ExcelExporter($sToken); + $aStatus = $oExcelExporter->Run(); + $aResults = array('status' => $aStatus['code'], 'percentage' => $aStatus['percentage'], 'message' => $aStatus['message']); + if ($aStatus['code'] == 'done') + { + $aResults['statistics'] = $oExcelExporter->GetStatistics('html'); + } + $oPage->add(json_encode($aResults)); + break; + + case 'xlsx_download': + $sToken = utils::ReadParam('token', '', false, 'raw_data'); + $oPage->SetContentType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $oPage->SetContentDisposition('attachment', 'export.xlsx'); + $sFileContent = ExcelExporter::GetExcelFileFromToken($sToken); + $oPage->add($sFileContent); + ExcelExporter::CleanupFromToken($sToken); + break; + + case 'xlsx_abort': + // Stop & cleanup an export... + $sToken = utils::ReadParam('token', '', false, 'raw_data'); + ExcelExporter::CleanupFromToken($sToken); + break; + + default: $oPage->p("Invalid query."); } diff --git a/portal/index.php b/portal/index.php index b7bc1b8fd..b2fb28fab 100644 --- a/portal/index.php +++ b/portal/index.php @@ -60,8 +60,7 @@ function ValidateObject($oObject) if (IsPowerUser()) { $sValidationDefine = 'PORTAL_'.strtoupper(get_class($oObject)).'_DISPLAY_POWERUSER_QUERY'; - } - else + } else { $sValidationDefine = 'PORTAL_'.strtoupper(get_class($oObject)).'_DISPLAY_QUERY'; } @@ -162,10 +161,17 @@ function DisplayMainMenu(WebPage $oP) $oP->AddMenuButton('showongoing', 'Portal:ShowOngoing', '../portal/index.php?operation=show_ongoing'); $oP->AddMenuButton('newrequest', 'Portal:CreateNewRequest', '../portal/index.php?operation=create_request'); $oP->AddMenuButton('showclosed', 'Portal:ShowClosed', '../portal/index.php?operation=show_closed'); - if (UserRights::CanChangePassword()) + $oP->AddMenuButton('showtoapprove', 'Portal:ShowToApprove', '../portal/index.php?operation=show_toapprove'); + + if (isKeyUser()) { + $oP->AddMenuButton('showtoresolve', 'Portal:ShowToResolve', '../portal/index.php?operation=show_toresolve'); + } +/* THEBEN + if (UserRights::CanChangePassword()) { $oP->AddMenuButton('change_pwd', 'Portal:ChangeMyPassword', '../portal/index.php?loginop=change_pwd'); - } + } */ + } /** @@ -184,6 +190,48 @@ function ShowOngoingTickets(WebPage $oP) $oP->add("

".Dict::S('Portal:ResolvedRequests')."

\n"); ListResolvedRequests($oP); $oP->add("\n"); + + $oP->add("
\n"); + $oP->add("

".Dict::S('Portal:RequestsToApprove')."

\n"); + ListRequestsToApprove($oP); + $oP->add("
\n"); + + if (isKeyUser()) { + $oP->add("
\n"); + $oP->add("

".Dict::S('Portal:RequestsToResolve')."

\n"); + ListRequestsToResolve($oP); + $oP->add("
\n"); + } +} + +/** + * Displays the tickets which need approval by mysel + * @param WebPage $oP The current web page + * @return void + */ +// =========================== THEBEN ================================== +function ShowTicketsToApprove(WebPage $oP) +{ + $oP->add("
\n"); + $oP->add("

".Dict::S('Portal:RequestsToApprove')."

\n"); + ListRequestsToApprove($oP); + $oP->add("
\n"); + +} + +/** + * Displays the tickets which need approval by mysel + * @param WebPage $oP The current web page + * @return void + */ +// =========================== THEBEN ================================== +function ShowTicketsToResolve(WebPage $oP) +{ + $oP->add("
\n"); + $oP->add("

".Dict::S('Portal:RequestsToResolve')."

\n"); + ListRequestsToResolve($oP); + $oP->add("
\n"); + } /** @@ -199,134 +247,6 @@ function ShowClosedTickets(WebPage $oP) $oP->add("\n"); } -/** - * Displays the form to select a Service Category Id (among the valid ones for the specified user Organization) - * @param WebPage $oP Web page for the form output - * @param Organization $oUserOrg The organization of the current user - * @return void - */ -function SelectServiceCategory($oP, $oUserOrg) -{ - $aParameters = $oP->ReadAllParams(PORTAL_ALL_PARAMS.',template_id'); - - $oSearch = DBObjectSearch::FromOQL(PORTAL_SERVICECATEGORY_QUERY); - $oSearch->AllowAllData(); // In case the user has the rights on his org only - $oSet = new CMDBObjectSet($oSearch, array(), array('org_id' => $oUserOrg->GetKey())); - if ($oSet->Count() == 1) - { - $oService = $oSet->Fetch(); - $iSvcCategory = $oService->GetKey(); - // Only one Category, skip this step in the wizard - SelectServiceSubCategory($oP, $oUserOrg, $iSvcCategory); - } - else - { - $oP->add("
\n"); - $oP->WizardFormStart('request_wizard', 1); - - $oP->add("

".Dict::S('Portal:SelectService')."

\n"); - $oP->add("\n"); - while($oService = $oSet->Fetch()) - { - $id = $oService->GetKey(); - $sChecked = ""; - if (isset($aParameters['service_id']) && ($id == $aParameters['service_id'])) - { - $sChecked = "checked"; - } - $oP->p(""); - } - $oP->add("

"); - $oP->p("

".$oService->GetAsHTML('description')."

\n"); - - $oP->DumpHiddenParams($aParameters, array('service_id')); - $oP->add(""); - $oP->WizardFormButtons(BUTTON_NEXT | BUTTON_CANCEL); // NO back button since it's the first step of the Wizard - $oP->WizardFormEnd(); - $oP->WizardCheckSelectionOnSubmit(Dict::S('Portal:PleaseSelectOneService')); - $oP->add("
\n"); - } -} - -/** - * Displays the form to select a Service Subcategory Id (among the valid ones for the specified user Organization) - * and based on the page's parameter 'service_id' - * @param WebPage $oP Web page for the form output - * @param Organization $oUserOrg The organization of the current user - * @param $iSvcId Id of the selected service in case of pass-through (when there is only one service) - * @return void - */ -function SelectServiceSubCategory($oP, $oUserOrg, $iSvcId = null) -{ - $aParameters = $oP->ReadAllParams(PORTAL_ALL_PARAMS.',template_id'); - if ($iSvcId == null) - { - $iSvcId = $aParameters['service_id']; - } - else - { - $aParameters['service_id'] = $iSvcId; - } - $iDefaultSubSvcId = isset($aParameters['servicesubcategory_id']) ? $aParameters['servicesubcategory_id'] : 0; - - $iDefaultWizNext = 2; - - $oSearch = DBObjectSearch::FromOQL(PORTAL_SERVICE_SUBCATEGORY_QUERY); - RestrictSubcategories($oSearch); - $oSearch->AllowAllData(); // In case the user has the rights on his org only - $oSet = new CMDBObjectSet($oSearch, array(), array('svc_id' => $iSvcId, 'org_id' => $oUserOrg->GetKey())); - if ($oSet->Count() == 1) - { - // Only one sub service, skip this step of the wizard - $oSubService = $oSet->Fetch(); - $iSubSvdId = $oSubService->GetKey(); - SelectRequestTemplate($oP, $oUserOrg, $iSvcId, $iSubSvdId); - } - else - { - $oServiceCategory = MetaModel::GetObject('Service', $iSvcId, false, true /* allow all data*/); - if (is_object($oServiceCategory)) - { - $oP->add("
\n"); - $oP->add("

".Dict::Format('Portal:SelectSubcategoryFrom_Service', $oServiceCategory->GetName())."

\n"); - $oP->WizardFormStart('request_wizard', $iDefaultWizNext); - $oP->add("\n"); - while($oSubService = $oSet->Fetch()) - { - $id = $oSubService->GetKey(); - $sChecked = ""; - if ($id == $iDefaultSubSvcId) - { - $sChecked = "checked"; - } - - $oP->add(""); - - $oP->add(""); - - $oP->add(""); - $oP->add(""); - } - $oP->add("
"); - $oP->add("

"); - $oP->add("
"); - $oP->add("

"); - $oP->add("

".$oSubService->GetAsHTML('description')."

"); - $oP->add("
\n"); - $oP->DumpHiddenParams($aParameters, array('servicesubcategory_id')); - $oP->add(""); - $oP->WizardFormButtons(BUTTON_BACK | BUTTON_NEXT | BUTTON_CANCEL); //Back button automatically discarded if on the first page - $oP->WizardFormEnd(); - $oP->WizardCheckSelectionOnSubmit(Dict::S('Portal:PleaseSelectAServiceSubCategory')); - $oP->add("
\n"); - } - else - { - $oP->p("Error: Invalid Service: id = $iSvcId"); - } - } -} - /** * Displays the form to select a Template * @param WebPage $oP Web page for the form output @@ -437,21 +357,9 @@ function SelectRequestTemplate($oP, $oUserOrg, $iSvcId = null, $iSubSvcId = null * @param integer $iTemplateId The identifier of the template (fall through when there is only one template) * @return void */ -function RequestCreationForm($oP, $oUserOrg, $iSvcId = null, $iSubSvcId = null, $iTemplateId = null) +function RequestCreationForm($oP, $oUserOrg) { $aParameters = $oP->ReadAllParams(PORTAL_ALL_PARAMS.',template_id'); - if (!is_null($iSvcId)) - { - $aParameters['service_id'] = $iSvcId; - } - if (!is_null($iSubSvcId)) - { - $aParameters['servicesubcategory_id'] = $iSubSvcId; - } - if (!is_null($iTemplateId)) - { - $aParameters['template_id'] = $iTemplateId; - } $sDescription = ''; if (isset($aParameters['template_id']) && ($aParameters['template_id'] != 0)) @@ -475,25 +383,33 @@ function RequestCreationForm($oP, $oUserOrg, $iSvcId = null, $iSubSvcId = null, } } - $oServiceCategory = MetaModel::GetObject('Service', $aParameters['service_id'], false, true /* allow all data*/); - $oServiceSubCategory = MetaModel::GetObject('ServiceSubcategory', $aParameters['servicesubcategory_id'], false, true /* allow all data*/); - if (is_object($oServiceCategory) && is_object($oServiceSubCategory)) + //$oServiceCategory = MetaModel::GetObject('Service', $aParameters['service_id'], false, true /* allow all data*/); + //$oServiceSubCategory = MetaModel::GetObject('ServiceSubcategory', $aParameters['servicesubcategory_id'], false, true /* allow all data*/); + //if (is_object($oServiceCategory) && is_object($oServiceSubCategory)) { - $sClass = ComputeClass($oServiceSubCategory->GetKey()); + $sClass = "UserRequest"; // ComputeClass($oServiceSubCategory->GetKey()); $oRequest = MetaModel::NewObject($sClass); $oRequest->Set('org_id', $oUserOrg->GetKey()); - $oRequest->Set('caller_id', UserRights::GetContactId()); - $oRequest->Set('service_id', $aParameters['service_id']); - $oRequest->Set('servicesubcategory_id', $aParameters['servicesubcategory_id']); + $oRequest->Set('caller_id', UserRights::GetContactId()); +// $oRequest->Set('service_id', $aParameters['service_id']); +// $oRequest->Set('servicesubcategory_id', $aParameters['servicesubcategory_id']); - $oAttDef = MetaModel::GetAttributeDef($sClass, 'service_id'); +/* $oAttDef = MetaModel::GetAttributeDef($sClass, 'service_id'); $aDetails[] = array('label' => $oAttDef->GetLabel(), 'value' => $oServiceCategory->GetName()); $oAttDef = MetaModel::GetAttributeDef($sClass, 'servicesubcategory_id'); $aDetails[] = array('label' => $oAttDef->GetLabel(), 'value' => $oServiceSubCategory->GetName()); +*/ $aList = explode(',', GetConstant($sClass, 'FORM_ATTRIBUTES')); - + + IssueLog::info("aList, FORM_ATTRIBUTES=".print_r($aList,true)); + // TODO + /* echo ""; */ + $iFlags = 0; foreach($aList as $sAttCode) { @@ -507,8 +423,10 @@ function RequestCreationForm($oP, $oUserOrg, $iSvcId = null, $iSubSvcId = null, $aFieldsMap = array(); foreach($aList as $sAttCode) { + IssueLog::Info("sAttCode=".$sAttCode); $value = ''; $oAttDef = MetaModel::GetAttributeDef($sClass, $sAttCode); + IssueLog::Info("oAttDef=".print_r($oAttDef,true)); $iFlags = $oRequest->GetAttributeFlags($sAttCode); if (isset($aParameters[$sAttCode])) { @@ -517,8 +435,10 @@ function RequestCreationForm($oP, $oUserOrg, $iSvcId = null, $iSubSvcId = null, $aArgs = array('this' => $oRequest); $sInputId = 'attr_'.$sAttCode; + IssueLog::Info("sInputId=".$sInputId); $aFieldsMap[$sAttCode] = $sInputId; $sValue = "".$oRequest->GetFormElementForField($oP, $sClass, $sAttCode, $oAttDef, $value, '', 'attr_'.$sAttCode, '', $iFlags, $aArgs).''; + IssueLog::Info("sValue=".$sValue); $aDetails[] = array('label' => $oAttDef->GetLabel(), 'value' => $sValue); } $aHidden = array(); @@ -527,6 +447,7 @@ function RequestCreationForm($oP, $oUserOrg, $iSvcId = null, $iSubSvcId = null, foreach ($aTemplateFields as $sAttCode => $oField) { $sValue = $oField->GetFormElement($oP, $sClass); + if ($oField->Get('input_type') == 'hidden') { $aHidden[] = $sValue; @@ -554,14 +475,14 @@ EOF $oP->add_linked_script("../js/jquery.blockUI.js"); $oP->add("
\n"); $oP->add("

".Dict::S('Portal:DescriptionOfTheRequest')."

\n"); - $oP->WizardFormStart('request_form', 4); + $oP->WizardFormStart('request_form', 1); $oP->details($aDetails); // Add hidden fields for known values, enabling dependant attributes to be computed correctly // foreach($oRequest->ListChanges() as $sAttCode => $value) - { + { if (!in_array($sAttCode, $aList)) { $oAttDef = MetaModel::GetAttributeDef($sClass, $sAttCode); @@ -587,7 +508,7 @@ EOF $oP->add("
\n"); $iFieldsCount = count($aFieldsMap); $sJsonFieldsMap = json_encode($aFieldsMap); - + // IssueLog::Info("sJsonFieldsMap=".$sJsonFieldsMap); $oP->add_ready_script( <<ReadAllParams(PORTAL_ALL_PARAMS.',template_id'); $sTransactionId = utils::ReadPostedParam('transaction_id', ''); if (!utils::IsTransactionValid($sTransactionId)) @@ -713,8 +634,8 @@ function CreateRequest(WebPage $oP, Organization $oUserOrg) { switch($oP->GetWizardStep()) { - case 0: - default: +// case 0: +/* default: SelectServiceCategory($oP, $oUserOrg); break; @@ -724,13 +645,13 @@ function CreateRequest(WebPage $oP, Organization $oUserOrg) case 2: SelectRequestTemplate($oP, $oUserOrg); - break; + break; */ - case 3: + case 0: RequestCreationForm($oP, $oUserOrg); break; - case 4: + case 1: DoCreateRequest($oP, $oUserOrg); break; } @@ -825,6 +746,54 @@ function ListResolvedRequests(WebPage $oP) DisplayRequestLists($oP, $aClassToSet); } +/** + * Lists all the currently resolved (not yet closed) User Requests for the current user + * @param WebPage $oP The current web page + * @return void + */ +// ========================= THEBEN =================================== +function ListRequestsToApprove(WebPage $oP) +{ + $oUserOrg = GetUserOrg(); + + $aClassToSet = array(); + foreach (GetTicketClasses() as $sClass) + { + $sOQL = "SELECT $sClass WHERE org_id = :org_id AND status = 'waiting_for_approval'"; + $oSearch = DBObjectSearch::FromOQL($sOQL); + $iUser = UserRights::GetContactId(); + + $oSearch->AddCondition('approver_id', $iUser); + + $aClassToSet[$sClass] = new CMDBObjectSet($oSearch, array(), array('org_id' => $oUserOrg->GetKey())); + } + DisplayRequestLists($oP, $aClassToSet); +} + +/** + * Lists all the currently resolved (not yet closed) User Requests for the current user + * @param WebPage $oP The current web page + * @return void + */ +// ========================= THEBEN =================================== +function ListRequestsToResolve(WebPage $oP) +{ + $oUserOrg = GetUserOrg(); + + $aClassToSet = array(); + foreach (GetTicketClasses() as $sClass) + { + $sOQL = "SELECT $sClass WHERE org_id = :org_id AND status = 'assigned'"; + $oSearch = DBObjectSearch::FromOQL($sOQL); + $iUser = UserRights::GetContactId(); + + $oSearch->AddCondition('agent_id', $iUser); + + $aClassToSet[$sClass] = new CMDBObjectSet($oSearch, array(), array('org_id' => $oUserOrg->GetKey())); + } + DisplayRequestLists($oP, $aClassToSet); +} + /** * Lists all the currently closed tickets * @param WebPage $oP The current web page @@ -896,6 +865,8 @@ function DisplayObject($oP, $oObj, $oUserOrg) * @param Object $oObj The target object * @return void */ +//============= THEBEN ============= ApproveButton === ResolveButton =============== + function ShowDetailsRequest(WebPage $oP, $oObj) { $sClass = get_class($oObj); @@ -905,6 +876,10 @@ function ShowDetailsRequest(WebPage $oP, $oObj) $bIsReopenButton = false; $bIsCloseButton = false; $bIsEscalateButton = false; + $bIsApproveButton = false; + $bIsRejectButton = false; + $bIsResolveButton = false; + $bEditAttachments = false; $aEditAtt = array(); // List of attributes editable in the main form if (!MetaModel::DBIsReadOnly()) @@ -923,16 +898,66 @@ function ShowDetailsRequest(WebPage $oP, $oObj) } // Add the "Close" button if this is valid action if (array_key_exists('ev_close', $aTransitions) && UserRights::IsStimulusAllowed($sClass, 'ev_close', $oSet)) - { - $bIsCloseButton = true; - MakeStimulusForm($oP, $oObj, 'ev_close', array('user_satisfaction', $sUserCommentAttCode)); + { if (($oObj->Get('caller_id') == UserRights::GetContactId()) + || IsPowerUSer()) { + // I can only close a ticket if I'm the caller or a power user + $bIsCloseButton = true; + MakeStimulusForm($oP, $oObj, 'ev_close', array('user_satisfaction', $sUserCommentAttCode)); + } + IssueLog::Info('caller_id='.$oObj->Get('caller_id')." user-id=".UserRights::GetContactId()); } break; + + case 'waiting_for_approval': + $aEditAtt = array(); + $aTransitions = $oObj->EnumTransitions(); + $oSet = DBObjectSet::FromObject($oObj); + // Add the "Approve" button if this is valid action + if (array_key_exists('ev_approve', $aTransitions)) //TODO check if current user is approver && UserRights::IsStimulusAllowed($sClass, 'ev_reopen', $oSet)) + { + $bIsApproveButton = true; + MakeStimulusForm($oP, $oObj, 'ev_approve', array($sLogAttCode)); + } + // Add the "Reject" button if this is valid action + if (array_key_exists('ev_reject', $aTransitions)) // TODO check if current user is approve && UserRights::IsStimulusAllowed($sClass, 'ev_close', $oSet)) + { + $bIsRejectButton = true; + MakeStimulusForm($oP, $oObj, 'ev_reject', array($sLogAttCode) ); //array('user_satisfaction', $sUserCommentAttCode)); + } + break; case 'closed': // By convention 'closed' is the final state of a ticket and nothing can be done in such a state break; + case 'assigned': + + $iFlags = $oObj->GetAttributeFlags($sLogAttCode); + $bReadOnly = (($iFlags & (OPT_ATT_READONLY | OPT_ATT_HIDDEN)) != 0); + if ($bReadOnly) + { + $aEditAtt = array(); + $bEditAttachments = false; + } + else + { + $aEditAtt = array( + $sLogAttCode => '????' + ); + $bEditAttachments = true; + } + if (isKeyUser()) { + $aTransitions = $oObj->EnumTransitions(); + $oSet = DBObjectSet::FromObject($oObj); + // Add the "Resolver" button if this is valid action + if (array_key_exists('ev_resolve', $aTransitions)) //TODO check if current user is approver && UserRights::IsStimulusAllowed($sClass, 'ev_reopen', $oSet)) + { + $bIsResolveButton = true; + MakeStimulusForm($oP, $oObj, 'ev_resolve', array($sLogAttCode)); + } + } + break; + default: // In all other states, the only possible action is to update the ticket (both the case log and the attachments) // This update is possible only if the case log field is not read-only or hidden in the current state @@ -1073,6 +1098,32 @@ EOF $sOk = addslashes(Dict::S('UI:Button:Ok')); $oP->p(''); } + + if($bIsApproveButton) + { + $sStimulusCode = 'ev_approve'; + $sTitle = addslashes(Dict::S('Portal:Button:ApproveTicket')); + $sOk = addslashes(Dict::S('UI:Button:Ok')); + $oP->p(''); + } + + if($bIsRejectButton) + { + $sStimulusCode = 'ev_reject'; + $sTitle = addslashes(Dict::S('Portal:Button:RejectTicket')); + $sOk = addslashes(Dict::S('UI:Button:Ok')); + $oP->p(''); + } + + if($bIsResolveButton) + { + $sStimulusCode = 'ev_resolve'; + $sTitle = addslashes(Dict::S('Portal:Button:ResolveTicket')); + $sOk = addslashes(Dict::S('UI:Button:Ok')); + $oP->p(''); + } + + if($bIsCloseButton) { $sStimulusCode = 'ev_close'; @@ -1257,6 +1308,26 @@ function IsPowerUSer() return $bRes; } +/** + * Determine if the current user can be considered as being a portal key user + * (can update tickets where he is agent and can resolve them) + */ +function IsKeyUSer() +{ + $iUserID = UserRights::GetUserId(); + $sOQLprofile = "SELECT URP_Profiles AS p JOIN URP_UserProfile AS up ON up.profileid=p.id WHERE up.userid = :user AND p.name = :profile"; + $oProfileSet = new DBObjectSet( + DBObjectSearch::FromOQL($sOQLprofile), + array(), + array( + 'user' => $iUserID, + 'profile' => 'PORTAL_KEY_USER_PROFILE', + ) + ); + $bRes = ($oProfileSet->count() > 0); + return $bRes; +} + /////////////////////////////////////////////////////////////////////////////// // // Main program @@ -1273,7 +1344,7 @@ try require_once(APPROOT.'/application/loginwebpage.class.inc.php'); LoginWebPage::DoLogin(false /* bMustBeAdmin */, true /* IsAllowedToPortalUsers */); // Check user rights and prompt if needed - ApplicationContext::SetUrlMakerClass('MyPortalURLMaker'); + ApplicationContext::SetUrlMakerClass('MyPortalURLMaker'); $aClasses = explode(',', MetaModel::GetConfig()->Get('portal_tickets')); $sMainClass = trim(reset($aClasses)); @@ -1288,9 +1359,11 @@ try $sCode = $oUserOrg->Get('code'); $sAlternateStylesheet = ''; + //IssueLog::Info("org code of user=".$sCode); if (@file_exists("./$sCode/portal.css")) { $sAlternateStylesheet = "$sCode"; + IssueLog::Info("using Alt Stylesheet: ".$sAlternateStylesheet); } $oP = new PortalWebPage(Dict::S('Portal:Title'), $sAlternateStylesheet); @@ -1349,12 +1422,27 @@ try DisplayObject($oP, $oObj, $oUserOrg); } break; - + + // =================== THEBEN =========================== + case 'show_toapprove': + $oP->set_title(Dict::S('Portal:ShowToApprove')); + DisplayMainMenu($oP); + ShowTicketsToApprove($oP); + break; + + // =================== THEBEN =========================== + case 'show_toresolve': + $oP->set_title(Dict::S('Portal:ShowToResolve')); + DisplayMainMenu($oP); + ShowTicketsToResolve($oP); + break; + case 'show_ongoing': default: $oP->set_title(Dict::S('Portal:ShowOngoing')); DisplayMainMenu($oP); ShowOngoingTickets($oP); + break; } } } diff --git a/setup/licenses/community-licences.xml b/setup/licenses/community-licences.xml index f6fb0bf77..b21a457a8 100644 --- a/setup/licenses/community-licences.xml +++ b/setup/licenses/community-licences.xml @@ -803,5 +803,30 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]]> + + PHP XLSXWriter + Mark Jones + MIT + Copyright (c) 2013 Mark Jones + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ]]> + diff --git a/webservices/export.php b/webservices/export.php index 4a6ff8b47..6a1098094 100644 --- a/webservices/export.php +++ b/webservices/export.php @@ -28,9 +28,11 @@ if (!defined('__DIR__')) define('__DIR__', dirname(__FILE__)); require_once(__DIR__.'/../approot.inc.php'); require_once(APPROOT.'/application/application.inc.php'); require_once(APPROOT.'/application/nicewebpage.class.inc.php'); +require_once(APPROOT.'/application/ajaxwebpage.class.inc.php'); require_once(APPROOT.'/application/csvpage.class.inc.php'); require_once(APPROOT.'/application/xmlpage.class.inc.php'); require_once(APPROOT.'/application/clipage.class.inc.php'); +require_once(APPROOT.'/application/excelexporter.class.inc.php'); require_once(APPROOT.'/application/startup.inc.php'); @@ -264,6 +266,32 @@ if (!empty($sExpression)) cmdbAbstractObject::DisplaySetAsXML($oP, $oSet, array('localize_values' => $bLocalize)); break; + case 'xlsx': + $oP = new ajax_page(''); + $oExporter = new ExcelExporter(); + $oExporter->SetObjectList($oFilter); + + // Run the export by chunk of 1000 objects to limit memory usage + $oExporter->SetChunkSize(1000); + do + { + $aStatus = $oExporter->Run(); // process one chunk + } + while( ($aStatus['code'] != 'done') && ($aStatus['code'] != 'error')); + + if ($aStatus['code'] == 'done') + { + $oP->SetContentType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $oP->SetContentDisposition('attachment', $oFilter->GetClass().'.xlsx'); + $oP->add(file_get_contents($oExporter->GetExcelFilePath())); + $oExporter->Cleanup(); + } + else + { + $oP->add('Error, xlsx export failed: '.$aStatus['message']); + } + break; + default: $oP = new WebPage("iTop - Export"); $oP->add("Unsupported format '$sFormat'. Possible values are: html, csv, spreadsheet or xml."); @@ -301,7 +329,7 @@ if (!$oP) $oP->p(" * expression: an OQL expression (URL encoded if needed)"); $oP->p(" * query: (alternative to 'expression') the id of an entry from the query phrasebook"); $oP->p(" * arg_xxx: (needed if the query has parameters) the value of the parameter 'xxx'"); - $oP->p(" * format: (optional, default is html) the desired output format. Can be one of 'html', 'spreadsheet', 'csv' or 'xml'"); + $oP->p(" * format: (optional, default is html) the desired output format. Can be one of 'html', 'spreadsheet', 'csv', 'xlsx' or 'xml'"); $oP->p(" * fields: (optional, no effect on XML format) list of fields (attribute codes, or alias.attcode) separated by a coma"); $oP->p(" * fields_advanced: (optional, no effect on XML/HTML formats ; ignored is fields is specified) If set to 1, the default list of fields will include the external keys and their reconciliation keys"); $oP->p(" * filename: (optional, no effect in CLI mode) if set then the results will be downloaded as a file");