diff --git a/application/itopwebpage.class.inc.php b/application/itopwebpage.class.inc.php index abb3a6646..79bc67dd9 100644 --- a/application/itopwebpage.class.inc.php +++ b/application/itopwebpage.class.inc.php @@ -79,6 +79,8 @@ class iTopWebPage extends NiceWebPage implements iTabbedPage $sSearchAny = addslashes(Dict::S('UI:SearchValue:Any')); $sSearchNbSelected = addslashes(Dict::S('UI:SearchValue:NbSelected')); $this->add_dict_entry('UI:FillAllMandatoryFields'); + $this->add_dict_entry('UI:Button:Cancel'); + $this->add_dict_entry('UI:Button:Done'); $bForceMenuPane = utils::ReadParam('force_menu_pane', null); $sInitClosed = ''; diff --git a/application/query.class.inc.php b/application/query.class.inc.php index 82dd3333d..9f8656f71 100644 --- a/application/query.class.inc.php +++ b/application/query.class.inc.php @@ -92,7 +92,7 @@ class QueryOQL extends Query if (!$bEditMode) { - $sUrl = utils::GetAbsoluteUrlAppRoot().'webservices/export.php?format=spreadsheet&login_mode=basic&query='.$this->GetKey(); + $sUrl = utils::GetAbsoluteUrlAppRoot().'webservices/export-v2.php?format=spreadsheet&login_mode=basic&query='.$this->GetKey(); $sOql = $this->Get('oql'); $sMessage = null; try diff --git a/application/utils.inc.php b/application/utils.inc.php index e021eaffe..365c230a4 100644 --- a/application/utils.inc.php +++ b/application/utils.inc.php @@ -487,19 +487,23 @@ class utils */ static public function GetAbsoluteUrlAppRoot() { - $sUrl = self::GetConfig()->Get('app_root_url'); - if (strpos($sUrl, SERVER_NAME_PLACEHOLDER) > -1) + static $sUrl = null; + if ($sUrl === null) { - if (isset($_SERVER['SERVER_NAME'])) + $sUrl = self::GetConfig()->Get('app_root_url'); + if (strpos($sUrl, SERVER_NAME_PLACEHOLDER) > -1) { - $sServerName = $_SERVER['SERVER_NAME']; + if (isset($_SERVER['SERVER_NAME'])) + { + $sServerName = $_SERVER['SERVER_NAME']; + } + else + { + // CLI mode ? + $sServerName = php_uname('n'); + } + $sUrl = str_replace(SERVER_NAME_PLACEHOLDER, $sServerName, $sUrl); } - else - { - // CLI mode ? - $sServerName = php_uname('n'); - } - $sUrl = str_replace(SERVER_NAME_PLACEHOLDER, $sServerName, $sUrl); } return $sUrl; } @@ -783,16 +787,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); + $oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/tabularfieldsselector.js'); + $oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/jquery.dragtable.js'); + $oPage->add_linked_stylesheet(utils::GetAbsoluteUrlAppRoot().'css/dragtable.css'); $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:CSVExport', Dict::S('UI:Menu:CSVExport'), "ExportListDlg('$sOQL', '$sDataTableId', 'csv', ".json_encode(Dict::S('UI:Menu:CSVExport')).")"), + new JSPopupMenuItem('UI:Menu:ExportXLSX', Dict::S('ExcelExporter:ExportMenu'), "ExportListDlg('$sOQL', '$sDataTableId', 'xlsx', ".json_encode(Dict::S('ExcelExporter:ExportMenu')).")"), 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')"), ); @@ -801,20 +805,26 @@ class utils case iPopupMenuExtension::MENU_OBJDETAILS_ACTIONS: // $param is a DBObject $oObj = $param; - $oFilter = DBobjectSearch::FromOQL("SELECT ".get_class($oObj)." WHERE id=".$oObj->GetKey()); + $sOQL = "SELECT ".get_class($oObj)." WHERE id=".$oObj->GetKey(); + $oFilter = DBObjectSearch::FromOQL($sOQL); $sFilter = $oFilter->serialize(); $sUrl = ApplicationContext::MakeObjectUrl(get_class($oObj), $oObj->GetKey()); $sUIPage = cmdbAbstractObject::ComputeStandardUIPage(get_class($oObj)); $oAppContext = new ApplicationContext(); $sContext = $oAppContext->GetForLink(); - $oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/xlsx-export.js'); - $sXlsxJSFilter = addslashes($sFilter); + $oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/tabularfieldsselector.js'); + $oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/jquery.dragtable.js'); + $oPage->add_linked_stylesheet(utils::GetAbsoluteUrlAppRoot().'css/dragtable.css'); + $oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/tabularfieldsselector.js'); + $oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/jquery.dragtable.js'); + $oPage->add_linked_stylesheet(utils::GetAbsoluteUrlAppRoot().'css/dragtable.css'); + $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()), + new JSPopupMenuItem('UI:Menu:CSVExport', Dict::S('UI:Menu:CSVExport'), "ExportListDlg('$sOQL', '', 'csv', ".json_encode(Dict::S('UI:Menu:CSVExport')).")"), + new JSPopupMenuItem('UI:Menu:ExportXLSX', Dict::S('ExcelExporter:ExportMenu'), "ExportListDlg('$sOQL', '', 'xlsx', ".json_encode(Dict::S('ExcelExporter:ExportMenu')).")"), ); break; diff --git a/application/xlsxwriter.class.php b/application/xlsxwriter.class.php index 8d42980d7..0a5ad2ad0 100644 --- a/application/xlsxwriter.class.php +++ b/application/xlsxwriter.class.php @@ -79,7 +79,7 @@ Class XLSXWriter } - public function writeSheet(array $data, $sheet_name='', array $header_types=array() ) + public function writeSheet(array $data, $sheet_name='', array $header_types=array(), array $header_row=array() ) { $data = empty($data) ? array( array('') ) : $data; @@ -95,7 +95,10 @@ Class XLSXWriter $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); + if (empty($header_row) && !empty($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; } diff --git a/core/bulkexport.class.inc.php b/core/bulkexport.class.inc.php new file mode 100644 index 000000000..5ef3a91f3 --- /dev/null +++ b/core/bulkexport.class.inc.php @@ -0,0 +1,410 @@ + + +define('EXPORTER_DEFAULT_CHUNK_SIZE', 1000); + +class BulkExportException extends Exception +{ + protected $sLocalizedMessage; + public function __construct($message, $sLocalizedMessage, $code = null, $previous = null) + { + parent::__construct($message, $code, $previous); + $this->sLocalizedMessage = $sLocalizedMessage; + } + + public function GetLocalizedMessage() + { + return $this->sLocalizedMessage; + } +} +class BulkExportMissingParameterException extends BulkExportException +{ + public function __construct($sFieldCode) + { + parent::__construct('Missing parameter: '.$sFieldCode, Dict::Format('Core:BulkExport:MissingParameter_Param', $sFieldCode)); + } + +} + +/** + * Class BulkExport + * + * @copyright Copyright (C) 2015 Combodo SARL + * @license http://opensource.org/licenses/AGPL-3.0 + */ + +class BulkExportResult extends DBObject +{ + public static function Init() + { + $aParams = array + ( + "category" => 'core/cmdb', + "key_type" => 'autoincrement', + "name_attcode" => array('created'), + "state_attcode" => '', + "reconc_keys" => array(), + "db_table" => 'priv_bulk_export_result', + "db_key_field" => 'id', + "db_finalclass_field" => '', + "display_template" => '', + ); + MetaModel::Init_Params($aParams); + + MetaModel::Init_AddAttribute(new AttributeDateTime("created", array("allowed_values"=>null, "sql"=>"created", "default_value"=>"", "is_null_allowed"=>false, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeInteger("user_id", array("allowed_values"=>null, "sql"=>"user_id", "default_value"=>0, "is_null_allowed"=>false, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeInteger("chunk_size", array("allowed_values"=>null, "sql"=>"chunk_size", "default_value"=>0, "is_null_allowed"=>true, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeString("format", array("allowed_values"=>null, "sql"=>"format", "default_value"=>'', "is_null_allowed"=>false, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeString("temp_file_path", array("allowed_values"=>null, "sql"=>"temp_file_path", "default_value"=>'', "is_null_allowed"=>true, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeLongText("search", array("allowed_values"=>null, "sql"=>"search", "default_value"=>'', "is_null_allowed"=>false, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeLongText("status_info", array("allowed_values"=>null, "sql"=>"status_info", "default_value"=>'', "is_null_allowed"=>false, "depends_on"=>array()))); + } + + public function ComputeValues() + { + $this->Set('user_id', UserRights::GetUserId()); + } +} + +/** + * Garbage collector for cleaning "old" export results from the database and the disk. + * This background process runs once per day and deletes the results of all exports which + * are older than one day. + */ +class BulkExportResultGC implements iBackgroundProcess +{ + public function GetPeriodicity() + { + return 24*3600; // seconds + } + + public function Process($iTimeLimit) + { + $sDateLimit = date('Y-m-d H:i:s', time() - 24*3600); // Every BulkExportResult older than one day will be deleted + + $sOQL = "SELECT BulkExportResult WHERE created < '$sDateLimit'"; + $iProcessed = 0; + while (time() < $iTimeLimit) + { + // Next one ? + $oSet = new CMDBObjectSet(DBObjectSearch::FromOQL($sOQL), array('created' => true) /* order by*/, array(), null, 1 /* limit count */); + $oSet->OptimizeColumnLoad(array('temp_file_path')); + $oResult = $oSet->Fetch(); + if (is_null($oResult)) + { + // Nothing to be done + break; + } + $iProcessed++; + @unlink($oResult->Get('temp_file_path')); + $oResult->DBDelete(); + } + return "Cleaned $iProcessed old export results(s)."; + } +} + +/** + * Class BulkExport + * + * @copyright Copyright (C) 2015 Combodo SARL + * @license http://opensource.org/licenses/AGPL-3.0 + */ + +abstract class BulkExport +{ + protected $oSearch; + protected $iChunkSize; + protected $sFormatCode; + protected $aStatusInfo; + protected $oBulkExportResult; + protected $sTmpFile; + + public function __construct() + { + $this->oSearch = null; + $this->iChunkSize = 0; + $this->sFormatCode = null; + $this->aStatusInfo = array(); + $this->oBulkExportResult = null; + $this->sTmpFile = ''; + } + + /** + * Find the first class capable of exporting the data in the given format + * @param string $sFormat The lowercase format (e.g. html, csv, spreadsheet, xlsx, xml, json, pdf...) + * @param DBObjectSearch $oSearch The search/filter defining the set of objects to export or null when listing the supported formats + * @return iBulkExport|NULL + */ + static public function FindExporter($sFormatCode, $oSearch = null) + { + foreach(get_declared_classes() as $sPHPClass) + { + $oRefClass = new ReflectionClass($sPHPClass); + if ($oRefClass->isSubclassOf('BulkExport') && !$oRefClass->isAbstract()) + { + $oBulkExporter = new $sPHPClass(); + if ($oBulkExporter->IsFormatSupported($sFormatCode, $oSearch)) + { + if ($oSearch) + { + $oBulkExporter->SetObjectList($oSearch); + } + return $oBulkExporter; + } + } + } + return null; + } + + /** + * Find the exporter corresponding to the given persistent token + * @param int $iPersistentToken The identifier of the BulkExportResult object storing the information + * @return iBulkExport|NULL + */ + static public function FindExporterFromToken($iPersistentToken = null) + { + $oBulkExporter = null; + $oInfo = MetaModel::GetObject('BulkExportResult', $iPersistentToken, false); + if ($oInfo && ($oInfo->Get('user_id') == UserRights::GetUserId())) + { + $sFormatCode = $oInfo->Get('format'); + $oSearch = DBObjectSearch::unserialize($oInfo->Get('search')); + + $oBulkExporter = self::FindExporter($sFormatCode, $oSearch); + if ($oBulkExporter) + { + $oBulkExporter->SetFormat($sFormatCode); + $oBulkExporter->SetObjectList($oSearch); + $oBulkExporter->SetChunkSize($oInfo->Get('chunk_size')); + $oBulkExporter->SetStatusInfo(json_decode($oInfo->Get('status_info'), true)); + $oBulkExporter->sTmpFile = $oInfo->Get('temp_file_path'); + $oBulkExporter->oBulkExportResult = $oInfo; + } + } + return $oBulkExporter; + } + + public function AppendToTmpFile($data) + { + if ($this->sTmpFile == '') + { + $this->sTmpFile = $this->MakeTmpFile($this->GetFileExtension()); + } + $hFile = fopen($this->sTmpFile, 'ab'); + if ($hFile !== false) + { + fwrite($hFile, $data); + fclose($hFile); + } + } + + public function GetTmpFilePath() + { + return $this->sTmpFile; + } + + /** + * Lists all possible export formats. The output is a hash array in the form: 'format_code' => 'localized format label' + * @return multitype:string + */ + static public function FindSupportedFormats() + { + $aSupportedFormats = array(); + foreach(get_declared_classes() as $sPHPClass) + { + $oRefClass = new ReflectionClass($sPHPClass); + if ($oRefClass->isSubClassOf('BulkExport') && !$oRefClass->isAbstract()) + { + $oBulkExporter = new $sPHPClass; + $aFormats = $oBulkExporter->GetSupportedFormats(); + $aSupportedFormats = array_merge($aSupportedFormats, $aFormats); + } + } + return $aSupportedFormats; + } + + /** + * (non-PHPdoc) + * @see iBulkExport::SetChunkSize() + */ + public function SetChunkSize($iChunkSize) + { + $this->iChunkSize = $iChunkSize; + } + + /** + * (non-PHPdoc) + * @see iBulkExport::SetObjectList() + */ + public function SetObjectList(DBObjectSearch $oSearch) + { + $this->oSearch = $oSearch; + } + + public function SetFormat($sFormatCode) + { + $this->sFormatCode = $sFormatCode; + } + + /** + * (non-PHPdoc) + * @see iBulkExport::IsFormatSupported() + */ + public function IsFormatSupported($sFormatCode, $oSearch = null) + { + return array_key_exists($sFormatCode, $this->GetSupportedFormats()); + } + + /** + * (non-PHPdoc) + * @see iBulkExport::GetSupportedFormats() + */ + public function GetSupportedFormats() + { + return array(); // return array('csv' => Dict::S('UI:ExportFormatCSV')); + } + + public function GetHeader() + { + + } + abstract public function GetNextChunk(&$aStatus); + public function GetFooter() + { + + } + + public function SaveState() + { + if ($this->oBulkExportResult === null) + { + $this->oBulkExportResult = new BulkExportResult(); + $this->oBulkExportResult->Set('format', $this->sFormatCode); + $this->oBulkExportResult->Set('search', $this->oSearch->serialize()); + $this->oBulkExportResult->Set('chunk_size', $this->iChunkSize); + $this->oBulkExportResult->Set('temp_file_path', $this->sTmpFile); + } + $this->oBulkExportResult->Set('status_info', json_encode($this->GetStatusInfo())); + return $this->oBulkExportResult->DBWrite(); + } + + public function Cleanup() + { + if (($this->oBulkExportResult && (!$this->oBulkExportResult->IsNew()))) + { + $sFilename = $this->oBulkExportResult->Get('temp_file_path'); + if ($sFilename != '') + { + @unlink($sFilename); + } + $this->oBulkExportResult->DBDelete(); + } + } + + public function EnumFormParts() + { + return array(); + } + + public function DisplayFormPart(WebPage $oP, $sPartId) + { + } + + public function DisplayUsage(Page $oP) + { + + } + public function ReadParameters() + { + + } + + public function GetResultAsHtml() + { + + } + public function GetRawResult() + { + + } + public function GetMimeType() + { + + } + public function GetFileExtension() + { + + } + + public function GetStatistics() + { + + } + + public function GetDownloadFileName() + { + return Dict::Format('Core:BulkExportOf_Class', MetaModel::GetName($this->oSearch->GetClass())).'.'.$this->GetFileExtension(); + } + + public function SetStatusInfo($aStatusInfo) + { + $this->aStatusInfo = $aStatusInfo; + } + + public function GetStatusInfo() + { + return $this->aStatusInfo; + } + + protected function MakeTmpFile($sExtension) + { + 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.'); + } + + $iNum = rand(); + $sFileName = ''; + do + { + $iNum++; + $sToken = sprintf("%08x", $iNum); + $sFileName = APPROOT."data/bulk_export/$sToken.".$sExtension; + $hFile = @fopen($sFileName, 'x'); + } + while($hFile === false); + + fclose($hFile); + return $sFileName; + } +} + +// The built-in exports +require_once(APPROOT.'core/tabularbulkexport.class.inc.php'); +require_once(APPROOT.'core/htmlbulkexport.class.inc.php'); +require_once(APPROOT.'core/pdfbulkexport.class.inc.php'); +require_once(APPROOT.'core/csvbulkexport.class.inc.php'); +require_once(APPROOT.'core/excelbulkexport.class.inc.php'); +require_once(APPROOT.'core/spreadsheetbulkexport.class.inc.php'); +require_once(APPROOT.'core/xmlbulkexport.class.inc.php'); + diff --git a/core/config.class.inc.php b/core/config.class.inc.php index e9fdc3e30..d9113bd92 100644 --- a/core/config.class.inc.php +++ b/core/config.class.inc.php @@ -957,6 +957,7 @@ class Config 'core/event.class.inc.php', 'core/action.class.inc.php', 'core/trigger.class.inc.php', + 'core/bulkexport.class.inc.php', 'synchro/synchrodatasource.class.inc.php', 'core/backgroundtask.class.inc.php', ); diff --git a/core/csvbulkexport.class.inc.php b/core/csvbulkexport.class.inc.php new file mode 100644 index 000000000..b236c1c80 --- /dev/null +++ b/core/csvbulkexport.class.inc.php @@ -0,0 +1,352 @@ + + +/** + * Bulk export: CSV export + * + * @copyright Copyright (C) 2015 Combodo SARL + * @license http://opensource.org/licenses/AGPL-3.0 + */ + +class CSVBulkExport extends TabularBulkExport +{ + public function DisplayUsage(Page $oP) + { + $oP->p(" * csv format options:"); + $oP->p(" *\tfields: (mandatory) the comma separated list of field codes to export (e.g: name,org_id,service_name...)."); + $oP->p(" *\tseparator: (optional) character to be used as the separator (default is ',')."); + $oP->p(" *\tcharacter-set: (optional) character set for encoding the result (default is 'UTF-8')."); + $oP->p(" *\ttext-qualifier: (optional) character to be used around text strings (default is '\"')."); + $oP->p(" *\tno_localize: set to 1 to retrieve non-localized values (for instance for ENUM values). Default is 0 (= localized values)"); + } + + public function ReadParameters() + { + parent::ReadParameters(); + $this->aStatusInfo['separator'] = utils::ReadParam('separator', ',', true, 'raw_data'); + if (strtolower($this->aStatusInfo['separator']) == 'tab') + { + $this->aStatusInfo['separator'] = "\t"; + } + else if (strtolower($this->aStatusInfo['separator']) == 'other') + { + $this->aStatusInfo['separator'] = utils::ReadParam('other-separator', ',', true, 'raw_data'); + } + + $this->aStatusInfo['text_qualifier'] = utils::ReadParam('text-qualifier', '"', true, 'raw_data'); + if (strtolower($this->aStatusInfo['text_qualifier']) == 'other') + { + $this->aStatusInfo['text_qualifier'] = utils::ReadParam('other-text-qualifier', '"', true, 'raw_data'); + } + $this->aStatusInfo['localize'] = !((bool)utils::ReadParam('no_localize', 0, true, 'integer')); + $this->aStatusInfo['charset'] = strtoupper(utils::ReadParam('character-set', 'UTF-8', true, 'raw_data')); + } + + public function EnumFormParts() + { + return array_merge(parent::EnumFormParts(), array('csv_options' => array('separator', 'character-set', 'text-qualifier', 'no_localize') ,'interactive_fields_csv' => array('interactive_fields_csv'))); + } + + public function DisplayFormPart(WebPage $oP, $sPartId) + { + switch($sPartId) + { + case 'interactive_fields_csv': + $this->GetInteractiveFieldsWidget($oP, 'interactive_fields_csv'); + break; + + case 'csv_options': + $oP->add('
'.Dict::S('Core:BulkExport:CSVOptions').''); + $oP->add('
'); + $oP->add('

'.Dict::S('UI:CSVImport:SeparatorCharacter').'

'); + $sRawSeparator = utils::ReadParam('separator', ',', true, 'raw_data'); + $aSep = array( + ';' => Dict::S('UI:CSVImport:SeparatorSemicolon+'), + ',' => Dict::S('UI:CSVImport:SeparatorComma+'), + 'tab' => Dict::S('UI:CSVImport:SeparatorTab+'), + ); + $sOtherSeparator = ''; + if (!array_key_exists($sRawSeparator, $aSep)) + { + $sOtherSeparator = $sRawSeparator; + $sRawSeparator = 'other'; + } + $aSep['other'] = Dict::S('UI:CSVImport:SeparatorOther').' '; + + foreach($aSep as $sVal => $sLabel) + { + $sChecked = ($sVal == $sRawSeparator) ? 'checked' : ''; + $oP->add(' '.$sLabel.'
'); + } + + $oP->add('
'); + + $oP->add('

'.Dict::S('UI:CSVImport:TextQualifierCharacter').'

'); + + $sRawQualifier = utils::ReadParam('text-qualifier', '"', true, 'raw_data'); + $aQualifiers = array( + '"' => Dict::S('UI:CSVImport:QualifierDoubleQuote+'), + '\'' => Dict::S('UI:CSVImport:QualifierSimpleQuote+'), + ); + $sOtherQualifier = ''; + if (!array_key_exists($sRawQualifier, $aQualifiers)) + { + $sOtherQualifier = $sRawQualifier; + $sRawQualifier = 'other'; + } + $aQualifiers['other'] = Dict::S('UI:CSVImport:QualifierOther').' '; + + foreach($aQualifiers as $sVal => $sLabel) + { + $sChecked = ($sVal == $sRawQualifier) ? 'checked' : ''; + $oP->add(' '.$sLabel.'
'); + } + + $sChecked = (utils::ReadParam('no_localize', 0) == 1) ? ' checked ' : ''; + $oP->add('
'); + $oP->add('

'.Dict::S('Core:BulkExport:CSVLocalization').'

'); + $oP->add(''); + $oP->add('
'); + + $oP->add('
'); + break; + + + default: + return parent:: DisplayFormPart($oP, $sPartId); + } + } + + protected function GetSampleData(DBObject $oObj, $sAttCode) + { + return trim($oObj->GetAsCSV($sAttCode), '"'); + } + + public function GetHeader() + { + $oSet = new DBObjectSet($this->oSearch); + $this->aStatusInfo['status'] = 'running'; + $this->aStatusInfo['position'] = 0; + $this->aStatusInfo['total'] = $oSet->Count(); + + $aSelectedClasses = $this->oSearch->GetSelectedClasses(); + $aAuthorizedClasses = array(); + foreach($aSelectedClasses as $sAlias => $sClassName) + { + if (UserRights::IsActionAllowed($sClassName, UR_ACTION_BULK_READ, $oSet) && (UR_ALLOWED_YES || UR_ALLOWED_DEPENDS)) + { + $aAuthorizedClasses[$sAlias] = $sClassName; + } + } + $aData = array(); + foreach($this->aStatusInfo['fields'] as $sExtendedAttCode) + { + if (preg_match('/^([^\.]+)\.(.+)$/', $sExtendedAttCode, $aMatches)) + { + $sAlias = $aMatches[1]; + $sAttCode = $aMatches[2]; + } + else + { + $sAlias = reset($aAuthorizedClasses); + $sAttCode = $sExtendedAttCode; + } + if (!array_key_exists($sAlias, $aAuthorizedClasses)) + { + throw new Exception("Invalid alias '$sAlias' for the column '$sExtendedAttCode'. Availables aliases: '".implode("', '", array_keys($aAuthorizedClasses))."'"); + } + $sClass = $aAuthorizedClasses[$sAlias]; + + if ($this->aStatusInfo['localize']) + { + switch($sAttCode) + { + case 'id': + if (count($aAuthorizedClasses) > 1) + { + $aData[] = $sAlias.'.id'; + } + else + { + $aData[] = 'id'; + } + break; + + default: + $oAttDef = MetaModel::GetAttributeDef($sClass, $sAttCode); + $sLabel = $this->aStatusInfo['localize'] ? $oAttDef->GetLabel() : $sAttCode; + if (count($aAuthorizedClasses) > 1) + { + $aData[] = $sAlias.'.'.$sLabel; + } + else + { + $aData[] = $sLabel; + } + } + } + else + { + $aData[] = $sExtendedAttCode; + } + } + $sFrom = array("\r\n", $this->aStatusInfo['text_qualifier']); + $sTo = array("\n", $this->aStatusInfo['text_qualifier'].$this->aStatusInfo['text_qualifier']); + foreach($aData as $idx => $sData) + { + // Escape and encode (if needed) the headers + $sEscaped = str_replace($sFrom, $sTo, (string)$sData); + $aData[$idx] = $this->aStatusInfo['text_qualifier'].$sEscaped.$this->aStatusInfo['text_qualifier']; + if ($this->aStatusInfo['charset'] != 'UTF-8') + { + // Note: due to bugs in the glibc library it's safer to call iconv on the smallest possible string + // and thus to convert field by field and not the whole row or file at once (see ticket #991) + $aData[$idx] = iconv('UTF-8', $this->aStatusInfo['charset'].'//IGNORE//TRANSLIT', $aData[$idx]); + } + } + $sData = implode($this->aStatusInfo['separator'], $aData)."\n"; + + return $sData; + } + + public function GetNextChunk(&$aStatus) + { + $sRetCode = 'run'; + $iPercentage = 0; + + $oSet = new DBObjectSet($this->oSearch); + $aSelectedClasses = $this->oSearch->GetSelectedClasses(); + $aAuthorizedClasses = array(); + foreach($aSelectedClasses as $sAlias => $sClassName) + { + if (UserRights::IsActionAllowed($sClassName, UR_ACTION_BULK_READ, $oSet) && (UR_ALLOWED_YES || UR_ALLOWED_DEPENDS)) + { + $aAuthorizedClasses[$sAlias] = $sClassName; + } + } + $oSet->SetLimit($this->iChunkSize, $this->aStatusInfo['position']); + + $aAliasByField = array(); + $aColumnsToLoad = array(); + + // Prepare the list of aliases / columns to load + foreach($this->aStatusInfo['fields'] as $sExtendedAttCode) + { + if (preg_match('/^([^\.]+)\.(.+)$/', $sExtendedAttCode, $aMatches)) + { + $sAlias = $aMatches[1]; + $sAttCode = $aMatches[2]; + } + else + { + $sAlias = reset($aAuthorizedClasses); + $sAttCode = $sExtendedAttCode; + } + + if (!array_key_exists($sAlias, $aAuthorizedClasses)) + { + throw new Exception("Invalid alias '$sAlias' for the column '$sExtendedAttCode'. Availables aliases: '".implode("', '", array_keys($aAuthorizedClasses))."'"); + } + + if (!array_key_exists($sAlias, $aColumnsToLoad)) + { + $aColumnsToLoad[$sAlias] = array(); + } + if ($sAttCode != 'id') + { + // id is not a real attribute code and, moreover, is always loaded + $aColumnsToLoad[$sAlias][] = $sAttCode; + } + $aAliasByField[$sExtendedAttCode] = array('alias' => $sAlias, 'attcode' => $sAttCode); + } + + $iCount = 0; + $sData = ''; + $oSet->OptimizeColumnLoad($aColumnsToLoad); + $iPreviousTimeLimit = ini_get('max_execution_time'); + $iLoopTimeLimit = MetaModel::GetConfig()->Get('max_execution_time_per_loop'); + while($aRow = $oSet->FetchAssoc()) + { + set_time_limit($iLoopTimeLimit); + $aData = array(); + foreach($aAliasByField as $aAttCode) + { + $sField = ''; + $oObj = $aRow[$aAttCode['alias']]; + if ($oObj != null) + { + switch($aAttCode['attcode']) + { + case 'id': + $sField = $oObj->GetKey(); + break; + + default: + $sField = $oObj->GetAsCSV($aAttCode['attcode'], $this->aStatusInfo['separator'], $this->aStatusInfo['text_qualifier'], $this->aStatusInfo['localize']); + } + if ($this->aStatusInfo['charset'] != 'UTF-8') + { + // Note: due to bugs in the glibc library it's safer to call iconv on the smallest possible string + // and thus to convert field by field and not the whole row or file at once (see ticket #991) + $aData[] = iconv('UTF-8', $this->aStatusInfo['charset'].'//IGNORE//TRANSLIT', $sField); + } + else + { + $aData[] = $sField; + } + } + } + $sData .= implode($this->aStatusInfo['separator'], $aData)."\n"; + $iCount++; + } + set_time_limit($iPreviousTimeLimit); + $this->aStatusInfo['position'] += $this->iChunkSize; + if ($this->aStatusInfo['total'] == 0) + { + $iPercentage = 100; + } + else + { + $iPercentage = floor(min(100.0, 100.0*$this->aStatusInfo['position']/$this->aStatusInfo['total'])); + } + + if ($iCount < $this->iChunkSize) + { + $sRetCode = 'done'; + } + + $aStatus = array('code' => $sRetCode, 'message' => Dict::S('Core:BulkExport:RetrievingData'), 'percentage' => $iPercentage); + return $sData; + } + + public function GetSupportedFormats() + { + return array('csv' => Dict::S('Core:BulkExport:CSVFormat')); + } + + public function GetMimeType() + { + return 'text/csv'; + } + + public function GetFileExtension() + { + return 'csv'; + } + +} diff --git a/core/excelbulkexport.class.inc.php b/core/excelbulkexport.class.inc.php new file mode 100644 index 000000000..69743db5d --- /dev/null +++ b/core/excelbulkexport.class.inc.php @@ -0,0 +1,309 @@ + + +/** + * Bulk export: Excel (xlsx) export + * + * @copyright Copyright (C) 2015 Combodo SARL + * @license http://opensource.org/licenses/AGPL-3.0 + */ + +require_once(APPROOT.'application/xlsxwriter.class.php'); + +class ExcelBulkExport extends TabularBulkExport +{ + protected $sData; + + public function __construct() + { + parent::__construct(); + $this->aStatusInfo['status'] = 'not_started'; + $this->aStatusInfo['position'] = 0; + } + + public function Cleanup() + { + @unlink($this->aStatusInfo['tmp_file']); + parent::Cleanup(); + } + + public function DisplayUsage(Page $oP) + { + $oP->p(" * xlsx format options:"); + $oP->p(" *\tfields: the comma separated list of field codes to export (e.g: name,org_id,service_name...)."); + } + + + public function EnumFormParts() + { + return array_merge(parent::EnumFormParts(), array('interactive_fields_xlsx' => array('interactive_fields_xlsx'))); + } + + public function DisplayFormPart(WebPage $oP, $sPartId) + { + switch($sPartId) + { + case 'interactive_fields_xlsx': + $this->GetInteractiveFieldsWidget($oP, 'interactive_fields_xlsx'); + break; + + default: + return parent:: DisplayFormPart($oP, $sPartId); + } + } + + public function ReadParameters() + { + parent::ReadParameters(); + $this->aStatusInfo['localize'] = !((bool)utils::ReadParam('no_localize', 0, true, 'integer')); + } + + + protected function SuggestField($aAliases, $sClass, $sAlias, $sAttCode) + { + switch($sAttCode) + { + case 'id': // replace 'id' by 'friendlyname' + $sAttCode = 'friendlyname'; + break; + + default: + $oAttDef = MetaModel::GetAttributeDef($sClass, $sAttCode); + if ($oAttDef instanceof AttributeExternalKey) + { + $sAttCode .= '_friendlyname'; + } + } + + return parent::SuggestField($aAliases, $sClass, $sAlias, $sAttCode); + } + + public function GetHeader() + { + $oSet = new DBObjectSet($this->oSearch); + $this->aStatusInfo['status'] = 'retrieving'; + $this->aStatusInfo['tmp_file'] = $this->MakeTmpFile('data'); + $this->aStatusInfo['position'] = 0; + $this->aStatusInfo['total'] = $oSet->Count(); + + $aSelectedClasses = $this->oSearch->GetSelectedClasses(); + $aTableHeaders = array(); + foreach($this->aStatusInfo['fields'] as $sExtendedAttCode) + { + if (preg_match('/^([^\.]+)\.(.+)$/', $sExtendedAttCode, $aMatches)) + { + $sAlias = $aMatches[1]; + $sAttCode = $aMatches[2]; + } + else + { + $sAlias = reset($aSelectedClasses); + $sAttCode = $sExtendedAttCode; + } + if (!array_key_exists($sAlias, $aSelectedClasses)) + { + throw new Exception("Invalid alias '$sAlias' for the column '$sExtendedAttCode'. Availables aliases: '".implode("', '", array_keys($aSelectedClasses))."'"); + } + $sClass = $aSelectedClasses[$sAlias]; + + $sFullAlias = ''; + if (count($aSelectedClasses) > 1) + { + $sFullAlias = $sAlias.'.'; + } + + switch($sAttCode) + { + case 'id': + $aTableHeaders[] = array('label' => $sFullAlias.'id', 'type' => '0'); + + break; + + default: + $oAttDef = MetaModel::GetAttributeDef($sClass, $sAttCode); + $sType = 'string'; + if($oAttDef instanceof AttributeDateTime) + { + $sType = 'datetime'; + } + $sLabel = $sAttCode; + if ($this->aStatusInfo['localize']) + { + $sLabel = $oAttDef->GetLabel(); + } + + $aTableHeaders[] = array('label' => $sFullAlias.$sLabel, 'type' => $sType); + } + } + + $sRow = json_encode($aTableHeaders); + $hFile = @fopen($this->aStatusInfo['tmp_file'], 'ab'); + if ($hFile === false) + { + throw new Exception('ExcelBulkExport: Failed to open temporary data file: "'.$this->aStatusInfo['tmp_file'].'" for writing.'); + } + fwrite($hFile, $sRow."\n"); + fclose($hFile); + return ''; + } + + public function GetNextChunk(&$aStatus) + { + $sRetCode = 'run'; + $iPercentage = 0; + + $hFile = fopen($this->aStatusInfo['tmp_file'], 'ab'); + $oSet = new DBObjectSet($this->oSearch); + $aSelectedClasses = $this->oSearch->GetSelectedClasses(); + $oSet->SetLimit($this->iChunkSize, $this->aStatusInfo['position']); + + $aAliasByField = array(); + $aColumnsToLoad = array(); + + // Prepare the list of aliases / columns to load + foreach($this->aStatusInfo['fields'] as $sExtendedAttCode) + { + if (preg_match('/^([^\.]+)\.(.+)$/', $sExtendedAttCode, $aMatches)) + { + $sAlias = $aMatches[1]; + $sAttCode = $aMatches[2]; + } + else + { + $sAlias = reset($aSelectedClasses); + $sAttCode = $sExtendedAttCode; + } + + if (!array_key_exists($sAlias, $aSelectedClasses)) + { + throw new Exception("Invalid alias '$sAlias' for the column '$sExtendedAttCode'. Availables aliases: '".implode("', '", array_keys($aSelectedClasses))."'"); + } + + if (!array_key_exists($sAlias, $aColumnsToLoad)) + { + $aColumnsToLoad[$sAlias] = array(); + } + if ($sAttCode != 'id') + { + // id is not a real attribute code and, moreover, is always loaded + $aColumnsToLoad[$sAlias][] = $sAttCode; + } + $aAliasByField[$sExtendedAttCode] = array('alias' => $sAlias, 'attcode' => $sAttCode); + } + + $iCount = 0; + $oSet->OptimizeColumnLoad($aColumnsToLoad); + $iPreviousTimeLimit = ini_get('max_execution_time'); + $iLoopTimeLimit = MetaModel::GetConfig()->Get('max_execution_time_per_loop'); + while($aRow = $oSet->FetchAssoc()) + { + set_time_limit($iLoopTimeLimit); + $aData = array(); + foreach($aAliasByField as $aAttCode) + { + $sField = ''; + switch($aAttCode['attcode']) + { + case 'id': + $sField = $aRow[$aAttCode['alias']]->GetKey(); + break; + + default: + $sField = $aRow[$aAttCode['alias']]->Get($aAttCode['attcode']); + } + $aData[] = $sField; + } + fwrite($hFile, json_encode($aData)."\n"); + $iCount++; + } + set_time_limit($iPreviousTimeLimit); + $this->aStatusInfo['position'] += $this->iChunkSize; + if ($this->aStatusInfo['total'] == 0) + { + $iPercentage = 100; + $sRetCode = 'done'; // Next phase (GetFooter) will be to build the xlsx file + } + else + { + $iPercentage = floor(min(100.0, 100.0*$this->aStatusInfo['position']/$this->aStatusInfo['total'])); + } + if ($iCount < $this->iChunkSize) + { + $sRetCode = 'done'; + } + $aStatus = array('code' => $sRetCode, 'message' => Dict::S('Core:BulkExport:RetrievingData'), 'percentage' => $iPercentage); + return ''; // The actual XLSX file is built in GetFooter(); + } + + public function GetFooter() + { + $hFile = @fopen($this->aStatusInfo['tmp_file'], 'rb'); + if ($hFile === false) + { + throw new Exception('ExcelBulkExport: Failed to open temporary data file: "'.$this->aStatusInfo['tmp_file'].'" for reading.'); + } + $sHeaders = fgets($hFile); + $aHeaders = json_decode($sHeaders, true); + + $aData = array(); + while($sLine = fgets($hFile)) + { + $aRow = json_decode($sLine); + $aData[] = $aRow; + } + fclose($hFile); + + $fStartExcel = microtime(true); + $writer = new XLSXWriter(); + $writer->setAuthor(UserRights::GetUserFriendlyName()); + $aHeaderTypes = array(); + $aHeaderNames = array(); + foreach($aHeaders as $Header) + { + $aHeaderNames[] = $Header['label']; + $aHeaderTypes[] = $Header['type']; + } + $writer->writeSheet($aData,'Sheet1', $aHeaderTypes, $aHeaderNames); + $fExcelTime = microtime(true) - $fStartExcel; + //$this->aStatistics['excel_build_duration'] = $fExcelTime; + + $fTime = microtime(true); + $data = $writer->writeToString(); + $fExcelSaveTime = microtime(true) - $fTime; + //$this->aStatistics['excel_write_duration'] = $fExcelSaveTime; + + @unlink($this->aStatusInfo['tmp_file']); + + return $data; + } + + public function GetMimeType() + { + return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + } + + public function GetFileExtension() + { + return 'xlsx'; + } + + public function GetSupportedFormats() + { + return array('xlsx' => Dict::S('Core:BulkExport:XLSXFormat')); + } +} \ No newline at end of file diff --git a/core/htmlbulkexport.class.inc.php b/core/htmlbulkexport.class.inc.php new file mode 100644 index 000000000..be3ff36ad --- /dev/null +++ b/core/htmlbulkexport.class.inc.php @@ -0,0 +1,243 @@ + + +/** + * Bulk export: HTML export + * + * @copyright Copyright (C) 2015 Combodo SARL + * @license http://opensource.org/licenses/AGPL-3.0 + */ + +class HTMLBulkExport extends TabularBulkExport +{ + public function DisplayUsage(Page $oP) + { + $oP->p(" * html format options:"); + $oP->p(" *\tfields: (mandatory) the comma separated list of field codes to export (e.g: name,org_id,service_name...)."); + } + + public function EnumFormParts() + { + return array_merge(parent::EnumFormParts(), array('interactive_fields_html' => array('interactive_fields_html'))); + } + + public function DisplayFormPart(WebPage $oP, $sPartId) + { + switch($sPartId) + { + case 'interactive_fields_html': + $this->GetInteractiveFieldsWidget($oP, 'interactive_fields_html'); + break; + + default: + return parent:: DisplayFormPart($oP, $sPartId); + } + } + + protected function GetSampleData(DBObject $oObj, $sAttCode) + { + return $oObj->GetAsHTML($sAttCode); + } + + public function GetHeader() + { + $oSet = new DBObjectSet($this->oSearch); + $this->aStatusInfo['status'] = 'running'; + $this->aStatusInfo['position'] = 0; + $this->aStatusInfo['total'] = $oSet->Count(); + + $aSelectedClasses = $this->oSearch->GetSelectedClasses(); + $aData = array(); + foreach($this->aStatusInfo['fields'] as $sExtendedAttCode) + { + if (preg_match('/^([^\.]+)\.(.+)$/', $sExtendedAttCode, $aMatches)) + { + $sAlias = $aMatches[1]; + $sAttCode = $aMatches[2]; + } + else + { + $sAlias = reset($aSelectedClasses); + $sAttCode = $sExtendedAttCode; + } + if (!array_key_exists($sAlias, $aSelectedClasses)) + { + throw new Exception("Invalid alias '$sAlias' for the column '$sExtendedAttCode'. Availables aliases: '".implode("', '", array_keys($aSelectedClasses))."'"); + } + $sClass = $aSelectedClasses[$sAlias]; + + switch($sAttCode) + { + case 'id': + if (count($aSelectedClasses) > 1) + { + $aData[] = $sAlias.'.id'; //@@@ + } + else + { + $aData[] = 'id'; //@@@ + } + break; + + default: + $oAttDef = MetaModel::GetAttributeDef($sClass, $sAttCode); + if (count($aSelectedClasses) > 1) + { + $aData[] = $sAlias.'.'.$oAttDef->GetLabel(); + } + else + { + $aData[] = $oAttDef->GetLabel(); + } + } + } + $sData = "\n"; + $sData .= "\n"; + $sData .= "\n"; + foreach($aData as $sLabel) + { + $sData .= "\n"; + } + $sData .= "\n"; + $sData .= "\n"; + $sData .= "\n"; + return $sData; + } + + public function GetNextChunk(&$aStatus) + { + $sRetCode = 'run'; + $iPercentage = 0; + + $oSet = new DBObjectSet($this->oSearch); + $aSelectedClasses = $this->oSearch->GetSelectedClasses(); + $oSet->SetLimit($this->iChunkSize, $this->aStatusInfo['position']); + + $aAliasByField = array(); + $aColumnsToLoad = array(); + + // Prepare the list of aliases / columns to load + foreach($this->aStatusInfo['fields'] as $sExtendedAttCode) + { + if (preg_match('/^([^\.]+)\.(.+)$/', $sExtendedAttCode, $aMatches)) + { + $sAlias = $aMatches[1]; + $sAttCode = $aMatches[2]; + } + else + { + $sAlias = reset($aSelectedClasses); + $sAttCode = $sExtendedAttCode; + } + + if (!array_key_exists($sAlias, $aSelectedClasses)) + { + throw new Exception("Invalid alias '$sAlias' for the column '$sExtendedAttCode'. Availables aliases: '".implode("', '", array_keys($aSelectedClasses))."'"); + } + + if (!array_key_exists($sAlias, $aColumnsToLoad)) + { + $aColumnsToLoad[$sAlias] = array(); + } + if ($sAttCode != 'id') + { + // id is not a real attribute code and, moreover, is always loaded + $aColumnsToLoad[$sAlias][] = $sAttCode; + } + $aAliasByField[$sExtendedAttCode] = array('alias' => $sAlias, 'attcode' => $sAttCode); + } + + $iCount = 0; + $sData = ''; + $oSet->OptimizeColumnLoad($aColumnsToLoad); + $iPreviousTimeLimit = ini_get('max_execution_time'); + $iLoopTimeLimit = MetaModel::GetConfig()->Get('max_execution_time_per_loop'); + while($aRow = $oSet->FetchAssoc()) + { + set_time_limit($iLoopTimeLimit); + $sFirstAlias = reset($aSelectedClasses); + $sHilightClass = $aRow[$sFirstAlias]->GetHilightClass(); + if ($sHilightClass != '') + { + $sData .= ""; + } + else + { + $sData .= ""; + } + foreach($aAliasByField as $aAttCode) + { + $sField = ''; + switch($aAttCode['attcode']) + { + case 'id': + $sField = $aRow[$aAttCode['alias']]->GetHyperlink(); + break; + + default: + $sField = $aRow[$aAttCode['alias']]->GetAsHtml($aAttCode['attcode']); + } + $sValue = ($sField === '') ? ' ' : $sField; + $sData .= ""; + } + $sData .= ""; + $iCount++; + } + set_time_limit($iPreviousTimeLimit); + $this->aStatusInfo['position'] += $this->iChunkSize; + if ($this->aStatusInfo['total'] == 0) + { + $iPercentage = 100; + } + else + { + $iPercentage = floor(min(100.0, 100.0*$this->aStatusInfo['position']/$this->aStatusInfo['total'])); + } + + if ($iCount < $this->iChunkSize) + { + $sRetCode = 'done'; + } + + $aStatus = array('code' => $sRetCode, 'message' => Dict::S('Core:BulkExport:RetrievingData'), 'percentage' => $iPercentage); + return $sData; + } + + public function GetFooter() + { + $sData = "\n"; + $sData .= "
".$sLabel."
$sValue
\n"; + + return $sData; + } + + public function GetSupportedFormats() + { + return array('html' => Dict::S('Core:BulkExport:HTMLFormat')); + } + + public function GetMimeType() + { + return 'text/html'; + } + + public function GetFileExtension() + { + return 'html'; + } +} diff --git a/core/pdfbulkexport.class.inc.php b/core/pdfbulkexport.class.inc.php new file mode 100644 index 000000000..3b3561547 --- /dev/null +++ b/core/pdfbulkexport.class.inc.php @@ -0,0 +1,149 @@ + + +/** + * Bulk export: PDF export, based on the HTML export converted to PDF + * + * @copyright Copyright (C) 2015 Combodo SARL + * @license http://opensource.org/licenses/AGPL-3.0 + */ + +require_once(APPROOT.'application/pdfpage.class.inc.php'); + +class PDFBulkExport extends HTMLBulkExport +{ + public function DisplayUsage(Page $oP) + { + $oP->p(" * pdf format options:"); + $oP->p(" *\tfields: (mandatory) the comma separated list of field codes to export (e.g: name,org_id,service_name...)."); + $oP->p(" *\tpage_size: (optional) size of the page. One of A4, A3, Letter (default is 'A4')."); + $oP->p(" *\tpage_orientation: (optional) the orientation of the page. Either Portrait or Landscape (default is 'Portrait')."); + } + + public function EnumFormParts() + { + return array_merge(array('pdf_options' => array('pdf_options')), parent::EnumFormParts()); + } + + public function DisplayFormPart(WebPage $oP, $sPartId) + { + switch($sPartId) + { + case 'pdf_options': + $oP->add('
'.Dict::S('Core:BulkExport:PDFOptions').''); + $oP->add(''); + $oP->add(''); + $oP->add(''); + $oP->add(''); + $oP->add(''); + $oP->add(''); + $oP->add(''); + $oP->add(''); + $oP->add('
'.Dict::S('Core:BulkExport:PDFPageSize').''.$this->GetSelectCtrl('page_size', array('A3', 'A4', 'Letter'), 'Core:BulkExport:PageSize-', 'A4').'
'.Dict::S('Core:BulkExport:PDFPageOrientation').''.$this->GetSelectCtrl('page_orientation', array('P', 'L'), 'Core:BulkExport:PageOrientation-', 'L').'
'); + + $oP->add('
'); + break; + + default: + return parent:: DisplayFormPart($oP, $sPartId); + } + } + + protected function GetSelectCtrl($sName, $aValues, $sDictPrefix, $sDefaultValue) + { + $sCurrentValue = utils::ReadParam($sName, $sDefaultValue, false, 'raw_data'); + $aLabels = array(); + foreach($aValues as $sVal) + { + $aLabels[$sVal] = Dict::S($sDictPrefix.$sVal); + } + asort($aLabels); + + $sHtml = ''; + return $sHtml; + } + + + public function ReadParameters() + { + parent::ReadParameters(); + $this->aStatusInfo['page_size'] = utils::ReadParam('page_size', 'A4', true, 'raw_data'); + $this->aStatusInfo['page_orientation'] = utils::ReadParam('page_orientation', 'L', true); + } + + public function GetHeader() + { + $this->aStatusInfo['tmp_file'] = $this->MakeTmpFile('data'); + $sData = parent::GetHeader(); + $hFile = @fopen($this->aStatusInfo['tmp_file'], 'ab'); + if ($hFile === false) + { + throw new Exception('PDFBulkExport: Failed to open temporary data file: "'.$this->aStatusInfo['tmp_file'].'" for writing.'); + } + fwrite($hFile, $sData."\n"); + fclose($hFile); + return ''; + } + + public function GetNextChunk(&$aStatus) + { + $sData = parent::GetNextChunk($aStatus); + $hFile = @fopen($this->aStatusInfo['tmp_file'], 'ab'); + if ($hFile === false) + { + throw new Exception('PDFBulkExport: Failed to open temporary data file: "'.$this->aStatusInfo['tmp_file'].'" for writing.'); + } + fwrite($hFile, $sData."\n"); + fclose($hFile); + return ''; + } + + public function GetFooter() + { + $sData = parent::GetFooter(); + + $oPage = new PDFPage(Dict::Format('Core:BulkExportOf_Class', MetaModel::GetName($this->oSearch->GetClass())), $this->aStatusInfo['page_size'], $this->aStatusInfo['page_orientation']); + $oPage->add(file_get_contents($this->aStatusInfo['tmp_file'])); + $oPage->add($sData); + + $sPDF = $oPage->get_pdf(); + + return $sPDF; + } + + public function GetSupportedFormats() + { + return array('pdf' => Dict::S('Core:BulkExport:PDFFormat')); + } + + public function GetMimeType() + { + return 'application/x-pdf'; + } + + public function GetFileExtension() + { + return 'pdf'; + } +} diff --git a/core/spreadsheetbulkexport.class.inc.php b/core/spreadsheetbulkexport.class.inc.php new file mode 100644 index 000000000..b088cce79 --- /dev/null +++ b/core/spreadsheetbulkexport.class.inc.php @@ -0,0 +1,316 @@ + + +/** + * Bulk export: "spreadsheet" export: a simplified HTML export in which the date/time columns are split in two column: date AND time +* +* @copyright Copyright (C) 2015 Combodo SARL +* @license http://opensource.org/licenses/AGPL-3.0 +*/ + +class SpreadsheetBulkExport extends TabularBulkExport +{ + public function DisplayUsage(Page $oP) + { + $oP->p(" * spreadsheet format options:"); + $oP->p(" *\tfields: (mandatory) the comma separated list of field codes to export (e.g: name,org_id,service_name...)."); + $oP->p(" *\tno_localize: (optional) pass 1 to retrieve the raw (untranslated) values for enumerated fields. Default: 0."); + } + + public function EnumFormParts() + { + return array_merge(parent::EnumFormParts(), array('spreadsheet_options' => array('no-localize') ,'interactive_fields_spreadsheet' => array('interactive_fields_spreadsheet'))); + } + + public function DisplayFormPart(WebPage $oP, $sPartId) + { + switch($sPartId) + { + case 'interactive_fields_spreadsheet': + $this->GetInteractiveFieldsWidget($oP, 'interactive_fields_spreadsheet'); + break; + + case 'spreadsheet_options': + $sChecked = (utils::ReadParam('no_localize', 0) == 1) ? ' checked ' : ''; + $oP->add('
'.Dict::S('Core:BulkExport:SpreadsheetOptions').''); + $oP->add(''); + $oP->add(''); + $oP->add(''); + $oP->add(''); + $oP->add('
'); + $oP->add('
'); + break; + + default: + return parent:: DisplayFormPart($oP, $sPartId); + } + } + + public function ReadParameters() + { + parent::ReadParameters(); + + $this->aStatusInfo['localize'] = (utils::ReadParam('no_localize', 0) != 1); + } + + protected function GetSampleData(DBObject $oObj, $sAttCode) + { + return $oObj->GetAsHTML($sAttCode); + } + + public function GetHeader() + { + $oSet = new DBObjectSet($this->oSearch); + $this->aStatusInfo['status'] = 'running'; + $this->aStatusInfo['position'] = 0; + $this->aStatusInfo['total'] = $oSet->Count(); + + $aSelectedClasses = $this->oSearch->GetSelectedClasses(); + $aData = array(); + foreach($this->aStatusInfo['fields'] as $sExtendedAttCode) + { + if (preg_match('/^([^\.]+)\.(.+)$/', $sExtendedAttCode, $aMatches)) + { + $sAlias = $aMatches[1]; + $sAttCode = $aMatches[2]; + } + else + { + $sAlias = reset($aSelectedClasses); + $sAttCode = $sExtendedAttCode; + } + if (!array_key_exists($sAlias, $aSelectedClasses)) + { + throw new Exception("Invalid alias '$sAlias' for the column '$sExtendedAttCode'. Availables aliases: '".implode("', '", array_keys($aSelectedClasses))."'"); + } + $sClass = $aSelectedClasses[$sAlias]; + + switch($sAttCode) + { + case 'id': + if (count($aSelectedClasses) > 1) + { + $aData[] = $sAlias.'.id'; //@@@ + } + else + { + $aData[] = 'id'; //@@@ + } + break; + + default: + $oAttDef = MetaModel::GetAttributeDef($sClass, $sAttCode); + $sColLabel = $this->aStatusInfo['localize'] ? MetaModel::GetLabel($sClass, $sAttCode) : $sAttCode; + $oFinalAttDef = $oAttDef->GetFinalAttDef(); + if (get_class($oFinalAttDef) == 'AttributeDateTime') + { + if (count($aSelectedClasses) > 1) + { + $aData[] = $sAlias.'.'.$sColLabel.' ('.Dict::S('UI:SplitDateTime-Date').')'; + $aData[] = $sAlias.'.'.$sColLabel.' ('.Dict::S('UI:SplitDateTime-Time').')'; + } + else + { + $aData[] = $sColLabel.' ('.Dict::S('UI:SplitDateTime-Date').')'; + $aData[] = $sColLabel.' ('.Dict::S('UI:SplitDateTime-Time').')'; + } + } + else + { + if (count($aSelectedClasses) > 1) + { + $aData[] = $sAlias.'.'.$sColLabel; + } + else + { + $aData[] = $sColLabel; + } + } + } + } + $sData = "\n"; + $sData .= "\n"; + foreach($aData as $sLabel) + { + $sData .= "\n"; + } + $sData .= "\n"; + return $sData; + } + + public function GetNextChunk(&$aStatus) + { + $sRetCode = 'run'; + $iPercentage = 0; + + $oSet = new DBObjectSet($this->oSearch); + $aSelectedClasses = $this->oSearch->GetSelectedClasses(); + $oSet->SetLimit($this->iChunkSize, $this->aStatusInfo['position']); + + $aAliasByField = array(); + $aColumnsToLoad = array(); + + // Prepare the list of aliases / columns to load + foreach($this->aStatusInfo['fields'] as $sExtendedAttCode) + { + if (preg_match('/^([^\.]+)\.(.+)$/', $sExtendedAttCode, $aMatches)) + { + $sAlias = $aMatches[1]; + $sAttCode = $aMatches[2]; + } + else + { + $sAlias = reset($aSelectedClasses); + $sAttCode = $sExtendedAttCode; + } + + if (!array_key_exists($sAlias, $aSelectedClasses)) + { + throw new Exception("Invalid alias '$sAlias' for the column '$sExtendedAttCode'. Availables aliases: '".implode("', '", array_keys($aSelectedClasses))."'"); + } + + if (!array_key_exists($sAlias, $aColumnsToLoad)) + { + $aColumnsToLoad[$sAlias] = array(); + } + if ($sAttCode != 'id') + { + // id is not a real attribute code and, moreover, is always loaded + $aColumnsToLoad[$sAlias][] = $sAttCode; + } + $aAliasByField[$sExtendedAttCode] = array('alias' => $sAlias, 'attcode' => $sAttCode); + } + + $iCount = 0; + $sData = ''; + $oSet->OptimizeColumnLoad($aColumnsToLoad); + $iPreviousTimeLimit = ini_get('max_execution_time'); + $iLoopTimeLimit = MetaModel::GetConfig()->Get('max_execution_time_per_loop'); + while($aRow = $oSet->FetchAssoc()) + { + set_time_limit($iLoopTimeLimit); + + $sData .= ""; + foreach($aAliasByField as $aAttCode) + { + $sField = ''; + $oObj = $aRow[$aAttCode['alias']]; + if ($oObj == null) + { + $sData .= ""; + continue; + } + + switch($aAttCode['attcode']) + { + case 'id': + $sField = $oObj->GetName(); + $sData .= ""; + break; + + default: + $oAttDef = MetaModel::GetAttributeDef(get_class($oObj), $aAttCode['attcode']); + $oFinalAttDef = $oAttDef->GetFinalAttDef(); + if (get_class($oFinalAttDef) == 'AttributeDateTime') + { + $iDate = AttributeDateTime::GetAsUnixSeconds($oObj->Get($aAttCode['attcode'])); + $sData .= ''; // Add the first column directly + $sField = date('H:i:s', $iDate); // Will add the second column below + $sData .= ""; + } + else if($oAttDef instanceof AttributeCaseLog) + { + $rawValue = $oObj->Get($aAttCode['attcode']); + $sField = str_replace("\n", "
", htmlentities($rawValue->__toString(), ENT_QUOTES, 'UTF-8')); + // Trick for Excel: treat the content as text even if it begins with an equal sign + $sData .= ""; + } + else + { + $rawValue = $oObj->Get($aAttCode['attcode']); + // Due to custom formatting rules, empty friendlynames may be rendered as non-empty strings + // let's fix this and make sure we render an empty string if the key == 0 + if ($oAttDef instanceof AttributeFriendlyName) + { + $sKeyAttCode = $oAttDef->GetKeyAttCode(); + if ($sKeyAttCode != 'id') + { + if ($oObj->Get($sKeyAttCode) == 0) + { + $sValue = ''; + } + } + } + if ($this->aStatusInfo['localize']) + { + $sField = htmlentities($oFinalAttDef->GetEditValue($rawValue), ENT_QUOTES, 'UTF-8'); + } + else + { + $sField = htmlentities($rawValue, ENT_QUOTES, 'UTF-8'); + } + $sData .= ""; + } + } + + } + $sData .= ""; + $iCount++; + } + set_time_limit($iPreviousTimeLimit); + $this->aStatusInfo['position'] += $this->iChunkSize; + if ($this->aStatusInfo['total'] == 0) + { + $iPercentage = 100; + } + else + { + $iPercentage = floor(min(100.0, 100.0*$this->aStatusInfo['position']/$this->aStatusInfo['total'])); + } + + if ($iCount < $this->iChunkSize) + { + $sRetCode = 'done'; + } + + $aStatus = array('code' => $sRetCode, 'message' => Dict::S('Core:BulkExport:RetrievingData'), 'percentage' => $iPercentage); + return $sData; + } + + public function GetFooter() + { + $sData = "
".$sLabel."
$sField$sField'.date('Y-m-d', $iDate).'$sField$sField$sField
\n"; + + return $sData; + } + + public function GetSupportedFormats() + { + return array('spreadsheet' => Dict::S('Core:BulkExport:SpreadsheetFormat')); + } + + public function GetMimeType() + { + return 'text/html'; + } + + public function GetFileExtension() + { + return 'html'; + } +} diff --git a/core/tabularbulkexport.class.inc.php b/core/tabularbulkexport.class.inc.php new file mode 100644 index 000000000..f1640c558 --- /dev/null +++ b/core/tabularbulkexport.class.inc.php @@ -0,0 +1,323 @@ + + +/** + * Bulk export: Tabular export: abstract base class for all "tabular" exports. + * Provides the user interface for selecting the column to be exported + * + * @copyright Copyright (C) 2015 Combodo SARL + * @license http://opensource.org/licenses/AGPL-3.0 + */ + +abstract class TabularBulkExport extends BulkExport +{ + public function EnumFormParts() + { + return array_merge(parent::EnumFormParts(), array('tabular_fields' => array('fields'))); + } + + public function DisplayFormPart(WebPage $oP, $sPartId) + { + switch($sPartId) + { + case 'tabular_fields': + $sFields = utils::ReadParam('fields', '', true, 'raw_data'); + $sSuggestedFields = utils::ReadParam('suggested_fields', null, true, 'raw_data'); + if (($sSuggestedFields !== null) && ($sSuggestedFields !== '')) + { + $aSuggestedFields = explode(',', $sSuggestedFields); + $sFields = implode(',', $this->SuggestFields($aSuggestedFields)); + } + $oP->add(''); + break; + + default: + return parent:: DisplayFormPart($oP, $sPartId); + } + } + + protected function SuggestFields($aSuggestedFields) + { + // By defaults all fields are Ok, nothing gets translated but + // you can overload this method if some fields are better exported + // (in a given format) by using an alternate field, for example id => friendlyname + $aAliases = $this->oSearch->GetSelectedClasses(); + foreach($aSuggestedFields as $idx => $sField) + { + if (preg_match('/^([^\\.]+)\\.(.+)$/', $sField, $aMatches)) + { + $sAlias = $aMatches[1]; + $sAttCode = $aMatches[2]; + $sClass = $aAliases[$sAlias]; + } + else + { + $sAlias = ''; + $sAttCode = $sField; + $sClass = reset($aAliases); + } + $aSuggestedFields[$idx] = $this->SuggestField($aAliases, $sClass, $sAlias, $sAttCode); + } + return $aSuggestedFields; + } + + protected function SuggestField($aAliases, $sClass, $sAlias, $sAttCode) + { + // Remove the aliases (if any) from the field names to make them compatible + // with the 'short' notation used in this case by the widget + if (count($aAliases) == 1) + { + return $sAttCode; + } + return $sAlias.'.'.$sAttCode; + } + + protected function IsSubAttribute($sClass, $sAttCode, $oAttDef) + { + return (($oAttDef instanceof AttributeFriendlyName) || ($oAttDef instanceof AttributeExternalField) || ($oAttDef instanceof AttributeSubItem)); + } + + protected function GetSubAttributes($sClass, $sAttCode, $oAttDef) + { + $aResult = array(); + switch(get_class($oAttDef)) + { + case 'AttributeExternalKey': + case 'AttributeHierarchicalKey': + $aResult[] = array('code' => $sAttCode, 'unique_label' => $oAttDef->GetLabel(), 'label' => Dict::S('Core:BulkExport:Identifier'), 'attdef' => $oAttDef); + $aResult[] = array('code' => $sAttCode.'_friendlyname', 'unique_label' => $oAttDef->GetLabel().'->'.Dict::S('Core:BulkExport:Friendlyname'), 'label' => Dict::S('Core:BulkExport:Friendlyname'), 'attdef' => MetaModel::GetAttributeDef($sClass, $sAttCode.'_friendlyname')); + + foreach(MetaModel::ListAttributeDefs($sClass) as $sSubAttCode => $oSubAttDef) + { + if ($oSubAttDef instanceof AttributeExternalField) + { + if ($oSubAttDef->GetKeyAttCode() == $sAttCode) + { + $aResult[] = array('code' => $sSubAttCode, 'unique_label' => $oAttDef->GetLabel().'->'.$oSubAttDef->GetExtAttDef()->GetLabel(), 'label' => $oSubAttDef->GetExtAttDef()->GetLabel(), 'attdef' => $oSubAttDef); + } + } + } + break; + + case 'AttributeStopWatch': + foreach(MetaModel::ListAttributeDefs($sClass) as $sSubAttCode => $oSubAttDef) + { + if ($oSubAttDef instanceof AttributeSubItem) + { + if ($oSubAttDef->GetParentAttCode() == $sAttCode) + { + $aResult[] = array('code' => $sSubAttCode, 'unique_label' => $oSubAttDef->GetLabel(), 'label' => $oSubAttDef->GetLabel(), 'attdef' => $oSubAttDef); + } + } + } + break; + + } + return $aResult; + } + + protected function GetInteractiveFieldsWidget(WebPage $oP, $sWidgetId) + { + $oSet = new DBObjectSet($this->oSearch); + $aSelectedClasses = $this->oSearch->GetSelectedClasses(); + $aAuthorizedClasses = array(); + foreach($aSelectedClasses as $sAlias => $sClassName) + { + if (UserRights::IsActionAllowed($sClassName, UR_ACTION_BULK_READ, $oSet) && (UR_ALLOWED_YES || UR_ALLOWED_DEPENDS)) + { + $aAuthorizedClasses[$sAlias] = $sClassName; + } + } + $aAllFieldsByAlias = array(); + foreach($aAuthorizedClasses as $sAlias => $sClass) + { + $aAllFields = array(); + if (count($aAuthorizedClasses) > 1 ) + { + $sShortAlias = $sAlias.'.'; + } + else + { + $sShortAlias = ''; + } + if ($this->IsValidField($sClass, 'id')) + { + $aAllFields[] = array('code' => $sShortAlias.'id', 'unique_label' => $sShortAlias.Dict::S('Core:BulkExport:Identifier'), 'label' => $sShortAlias.'id', 'subattr' => array( + array('code' => $sShortAlias.'id', 'unique_label' => $sShortAlias.Dict::S('Core:BulkExport:Identifier'), 'label' => $sShortAlias.'id'), + array('code' => $sShortAlias.'friendlyname', 'unique_label' => $sShortAlias.Dict::S('Core:BulkExport:Friendlyname'), 'label' => $sShortAlias.Dict::S('Core:BulkExport:Friendlyname')), + )); + } + foreach(MetaModel::ListAttributeDefs($sClass) as $sAttCode => $oAttDef) + { + if($this->IsSubAttribute($sClass, $sAttCode, $oAttDef)) continue; + + if ($this->IsValidField($sClass, $sAttCode, $oAttDef)) + { + $sShortLabel = $oAttDef->GetLabel(); + $sLabel = $sShortAlias.$oAttDef->GetLabel(); + $aSubAttr = $this->GetSubAttributes($sClass, $sAttCode, $oAttDef); + $aValidSubAttr = array(); + foreach($aSubAttr as $aSubAttDef) + { + if ($this->IsValidField($sClass, $aSubAttDef['code'], $aSubAttDef['attdef'])) + { + $aValidSubAttr[] = array('code' => $sShortAlias.$aSubAttDef['code'], 'label' => $aSubAttDef['label'], 'unique_label' => $aSubAttDef['unique_label']); + } + } + $aAllFields[] = array('code' => $sShortAlias.$sAttCode, 'label' => $sShortLabel, 'unique_label' => $sLabel, 'subattr' => $aValidSubAttr); + } + } + usort($aAllFields, array(get_class($this), 'SortOnLabel')); + if (count($aAuthorizedClasses) > 1) + { + $sKey = MetaModel::GetName($sClass).' ('.$sAlias.')'; + } + else + { + $sKey = MetaModel::GetName($sClass); + } + $aAllFieldsByAlias[$sKey] = $aAllFields; + } + + $oP->add('
'); + $JSAllFields = json_encode($aAllFieldsByAlias); + $oSet = new DBObjectSet($this->oSearch); + $iCount = $oSet->Count(); + $iPreviewLimit = 3; + $oSet->SetLimit($iPreviewLimit); + $aSampleData = array(); + while($aRow = $oSet->FetchAssoc()) + { + $aSampleRow = array(); + foreach($aAuthorizedClasses as $sAlias => $sClass) + { + if (count($aAuthorizedClasses) > 1 ) + { + $sShortAlias = $sAlias.'.'; + } + else + { + $sShortAlias = ''; + } + + if ($this->IsValidField($sClass, 'id')) + { + $aSampleRow[$sShortAlias.'id'] = $this->GetSampleKey($aRow[$sAlias]); + } + foreach(MetaModel::ListAttributeDefs($sClass) as $sAttCode => $oAttDef) + { + if ($this->IsValidField($sClass, $sAttCode, $oAttDef)) + { + $aSampleRow[$sShortAlias.$sAttCode] = $this->GetSampleData($aRow[$sAlias], $sAttCode); + } + } + } + $aSampleData[] = $aSampleRow; + } + $sJSSampleData = json_encode($aSampleData); + $aLabels = array( + 'preview_header' => Dict::S('Core:BulkExport:DragAndDropHelp'), + 'empty_preview' => Dict::S('Core:BulkExport:EmptyPreview'), + 'columns_order' => Dict::S('Core:BulkExport:ColumnsOrder'), + 'columns_selection' => Dict::S('Core:BulkExport:AvailableColumnsFrom_Class'), + 'check_all' => Dict::S('Core:BulkExport:CheckAll'), + 'uncheck_all' => Dict::S('Core:BulkExport:UncheckAll'), + 'no_field_selected' => Dict::S('Core:BulkExport:NoFieldSelected'), + ); + $sJSLabels = json_encode($aLabels); + $oP->add_ready_script( +<<IsScalar(); + } + + /** + * Tells if the specified field is part of the "advanced" fields + * @param unknown $sClass + * @param unknown $sAttCode + * @param AttributeDefinition $oAttDef Can be null when $sAttCode == 'id' + * @return boolean + */ + protected function IsAdvancedValidField($sClass, $sAttCode, $oAttDef = null) + { + return (($sAttCode == 'id') || ($oAttDef instanceof AttributeExternalKey)); + } + + protected function GetSampleData(DBObject $oObj, $sAttCode) + { + if ($oObj == null) return ''; + return $oObj->GetEditValue($sAttCode); + } + + protected function GetSampleKey(DBObject $oObj) + { + if ($oObj == null) return ''; + return $oObj->GetKey(); + } + + public function ReadParameters() + { + parent::ReadParameters(); + $sQueryId = utils::ReadParam('query', null, true); + $sFields = utils::ReadParam('fields', null, true, 'raw_data'); + if ((($sFields === null) || ($sFields === '')) && ($sQueryId === null)) + { + throw new BulkExportMissingParameterException('fields'); + } + else if(($sQueryId !== null) && ($sQueryId !== null)) + { + $oSearch = DBObjectSearch::FromOQL('SELECT QueryOQL WHERE id = :query_id', array('query_id' => $sQueryId)); + $oQueries = new DBObjectSet($oSearch); + if ($oQueries->Count() > 0) + { + $oQuery = $oQueries->Fetch(); + $sFields = trim($oQuery->Get('fields')); + if ($sFields === '') + { + throw new BulkExportMissingParameterException('fields'); + } + } + else + { + throw BulkExportException('Invalid value for the parameter: query. There is no Query Phrasebook with id = '.$sQueryId, Dict::Format('Core:BulkExport:InvalidParameter_Query', $sQueryId)); + } + } + + $this->aStatusInfo['fields'] = explode(',', $sFields); + } +} diff --git a/core/xmlbulkexport.class.inc.php b/core/xmlbulkexport.class.inc.php new file mode 100644 index 000000000..d030b6700 --- /dev/null +++ b/core/xmlbulkexport.class.inc.php @@ -0,0 +1,196 @@ + + +/** + * Bulk export: XML export + * + * @copyright Copyright (C) 2015 Combodo SARL + * @license http://opensource.org/licenses/AGPL-3.0 + */ + +class XMLBulkExport extends BulkExport +{ + public function DisplayUsage(Page $oP) + { + $oP->p(" * xml format options:"); + $oP->p(" *\tThere are no options for the XML format."); + } + + public function EnumFormParts() + { + return array_merge(parent::EnumFormParts(), array('xml_options' => array('xml_no_options'))); + } + + public function DisplayFormPart(WebPage $oP, $sPartId) + { + switch($sPartId) + { + case 'xml_options': + $sChecked = (utils::ReadParam('no_localize', 0) == 1) ? ' checked ' : ''; + $oP->add('
'.Dict::S('Core:BulkExport:XMLOptions').''); + $oP->add(''); + $oP->add(''); + $oP->add(''); + $oP->add(''); + $oP->add('
'); + $oP->add('
'); + break; + + default: + return parent:: DisplayFormPart($oP, $sPartId); + } + } + + public function ReadParameters() + { + parent::ReadParameters(); + + $this->aStatusInfo['localize'] = (utils::ReadParam('no_localize', 0) != 1); + } + + protected function GetSampleData(DBObject $oObj, $sAttCode) + { + return $oObj->GetAsXML($sAttCode); + } + + public function GetHeader() + { + $oSet = new DBObjectSet($this->oSearch); + $this->aStatusInfo['position'] = 0; + $this->aStatusInfo['total'] = $oSet->Count(); + $sData = "<"."?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n"; + return $sData; + } + + public function GetNextChunk(&$aStatus) + { + $sRetCode = 'run'; + $iPercentage = 0; + + $iCount = 0; + $sData = ''; + + $oSet = new DBObjectSet($this->oSearch); + $oSet->SetLimit($this->iChunkSize, $this->aStatusInfo['position']); + + $bLocalize = $this->aStatusInfo['localize']; + + $aClasses = $this->oSearch->GetSelectedClasses(); + $aAuthorizedClasses = array(); + foreach($aClasses as $sAlias => $sClassName) + { + if (UserRights::IsActionAllowed($sClassName, UR_ACTION_BULK_READ, $oSet) && (UR_ALLOWED_YES || UR_ALLOWED_DEPENDS)) + { + $aAuthorizedClasses[$sAlias] = $sClassName; + } + } + $aAttribs = array(); + $aList = array(); + $aList[$sAlias] = MetaModel::GetZListItems($sClassName, 'details'); + + $iPreviousTimeLimit = ini_get('max_execution_time'); + $iLoopTimeLimit = MetaModel::GetConfig()->Get('max_execution_time_per_loop'); + + while ($aObjects = $oSet->FetchAssoc()) + { + set_time_limit($iLoopTimeLimit); + if (count($aAuthorizedClasses) > 1) + { + $sData .= "\n"; + } + foreach($aAuthorizedClasses as $sAlias => $sClassName) + { + $oObj = $aObjects[$sAlias]; + if (is_null($oObj)) + { + $sData .= "<$sClassName alias=\"$sAlias\" id=\"null\">\n"; + } + else + { + $sClassName = get_class($oObj); + $sData .= "<$sClassName alias=\"$sAlias\" id=\"".$oObj->GetKey()."\">\n"; + } + foreach(MetaModel::ListAttributeDefs($sClassName) as $sAttCode=>$oAttDef) + { + if (is_null($oObj)) + { + $sData .= "<$sAttCode>null\n"; + } + else + { + if ($oAttDef->IsWritable()) + { + if (!$oAttDef->IsLinkSet()) + { + $sValue = $oObj->GetAsXML($sAttCode, $bLocalize); + $sData .= "<$sAttCode>$sValue\n"; + } + } + } + } + $sData .= "\n"; + } + if (count($aAuthorizedClasses) > 1) + { + $sData .= "\n"; + } + $iCount++; + } + + set_time_limit($iPreviousTimeLimit); + $this->aStatusInfo['position'] += $this->iChunkSize; + if ($this->aStatusInfo['total'] == 0) + { + $iPercentage = 100; + } + else + { + $iPercentage = floor(min(100.0, 100.0*$this->aStatusInfo['position']/$this->aStatusInfo['total'])); + } + + if ($iCount < $this->iChunkSize) + { + $sRetCode = 'done'; + } + + $aStatus = array('code' => $sRetCode, 'message' => Dict::S('Core:BulkExport:RetrievingData'), 'percentage' => $iPercentage); + return $sData; + } + + public function GetFooter() + { + $sData = "\n"; + + return $sData; + } + + public function GetSupportedFormats() + { + return array('xml' => Dict::S('Core:BulkExport:XMLFormat')); + } + + public function GetMimeType() + { + return 'text/xml'; + } + + public function GetFileExtension() + { + return 'xml'; + } +} diff --git a/css/dragtable.css b/css/dragtable.css new file mode 100644 index 000000000..eea04bbf3 --- /dev/null +++ b/css/dragtable.css @@ -0,0 +1,40 @@ +/* + * dragtable + * + * @Version 2.0.14 + * + * default css + * + */ +/*##### the dragtable stuff #####*/ +.dragtable-sortable { + list-style-type: none; margin: 0; padding: 0; -moz-user-select: none; +} +.dragtable-sortable li { + margin: 0; padding: 0; float: left; font-size: 1em; background: white; +} + +.dragtable-sortable th, .dragtable-sortable td{ + border-left: 0px; +} + +.dragtable-sortable li:first-child th, .dragtable-sortable li:first-child td { + border-left: 1px solid #CCC; +} + +.ui-sortable-helper { + opacity: 0.7;filter: alpha(opacity=70); +} +.ui-sortable-placeholder { + -moz-box-shadow: 4px 5px 4px #C6C6C6 inset; + -webkit-box-shadow: 4px 5px 4px #C6C6C6 inset; + box-shadow: 4px 5px 4px #C6C6C6 inset; + border-bottom: 1px solid #CCCCCC; + border-top: 1px solid #CCCCCC; + visibility: visible !important; + background: #EFEFEF !important; + visibility: visible !important; +} +.ui-sortable-placeholder * { + opacity: 0.0; visibility: hidden; +} diff --git a/css/light-grey.css b/css/light-grey.css index 39e1204bc..e3aa0e624 100644 --- a/css/light-grey.css +++ b/css/light-grey.css @@ -1943,3 +1943,28 @@ div.ui-dialog-header { } +table.export_parameters td { + padding-right: 2em; +} + + +.table_preview > table { + border-collapse: collapse; +} + + +.table_preview > table > tbody > tr > td, .table_preview > table > thead > tr > th { + border: 1px #555555 solid; + min-height: 1em; + padding-left: 0.25em; + padding-right: 0.25em; + font-size: 10pt; +} + + +.table_preview .nodragtable-sortable li { + border: 1px #555555 solid; + font-size: 10pt; +} + + diff --git a/css/light-grey.scss b/css/light-grey.scss index 46ffc0481..4a53a929a 100644 --- a/css/light-grey.scss +++ b/css/light-grey.scss @@ -1436,4 +1436,21 @@ div.ui-dialog-header { .arrow.top:after { bottom: -20px; top: auto; +} +table.export_parameters td { + padding-right: 2em; +} +.table_preview > table { + border-collapse: collapse; +} +.table_preview > table > thead > tr > th, .table_preview > table > tbody > tr > td { + border: 1px $grey-color solid; + min-height: 1em; + padding-left: 0.25em; + padding-right: 0.25em; + font-size: 10pt; +} +.table_preview .nodragtable-sortable li { + border: 1px $grey-color solid; + font-size: 10pt; } \ No newline at end of file diff --git a/dictionaries/da.dictionary.itop.ui.php b/dictionaries/da.dictionary.itop.ui.php index 4a7f25552..8207d4832 100644 --- a/dictionaries/da.dictionary.itop.ui.php +++ b/dictionaries/da.dictionary.itop.ui.php @@ -328,7 +328,7 @@ Dict::Add('DA DA', 'Danish', 'Dansk', array( 'UI:Menu:Add' => 'Tilføj...', 'UI:Menu:Manage' => 'Administrer...', 'UI:Menu:EMail' => 'eMail', - 'UI:Menu:CSVExport' => 'CSV Eksport', + 'UI:Menu:CSVExport' => 'CSV Eksport...', 'UI:Menu:Modify' => 'Modificer...', 'UI:Menu:Delete' => 'Slet...', 'UI:Menu:BulkDelete' => 'Slet...', diff --git a/dictionaries/de.dictionary.itop.ui.php b/dictionaries/de.dictionary.itop.ui.php index 9f03599f2..bbe91fb79 100644 --- a/dictionaries/de.dictionary.itop.ui.php +++ b/dictionaries/de.dictionary.itop.ui.php @@ -325,7 +325,7 @@ Dict::Add('DE DE', 'German', 'Deutsch', array( 'UI:Menu:Add' => 'Hinzufügen...', 'UI:Menu:Manage' => 'Verwalten...', 'UI:Menu:EMail' => 'eMail', - 'UI:Menu:CSVExport' => 'CSV-Export', + 'UI:Menu:CSVExport' => 'CSV-Export...', 'UI:Menu:Modify' => 'Modifizieren...', 'UI:Menu:Delete' => 'Löschen...', 'UI:Menu:BulkDelete' => 'Löschen...', diff --git a/dictionaries/dictionary.itop.core.php b/dictionaries/dictionary.itop.core.php index f38280cf4..e5a31b6e9 100644 --- a/dictionaries/dictionary.itop.core.php +++ b/dictionaries/dictionary.itop.core.php @@ -787,5 +787,49 @@ Dict::Add('EN US', 'English', 'English', array( // Explain working time computing 'Core:ExplainWTC:ElapsedTime' => 'Time elapsed (stored as "%1$s")', 'Core:ExplainWTC:StopWatch-TimeSpent' => 'Time spent for "%1$s"', - 'Core:ExplainWTC:StopWatch-Deadline' => 'Deadline for "%1$s" at %2$d%%' + 'Core:ExplainWTC:StopWatch-Deadline' => 'Deadline for "%1$s" at %2$d%%', + + // Bulk export + 'Core:BulkExport:MissingParameter_Param' => 'Missing parameter "%1$s"', + 'Core:BulkExport:InvalidParameter_Query' => 'Invalid value for the parameter "query". There is no Query Phrasebook corresponding to the id: "%1$s".', + 'Core:BulkExport:ExportFormatPrompt' => 'Export format:', + 'Core:BulkExport:Identifier' => 'Identifier', + 'Core:BulkExport:Friendlyname' => 'Full name', + 'Core:BulkExportOf_Class' => '%1$s Export', + 'Core:BulkExport:ClickHereToDownload_FileName' => 'Click here to download %1$s', + 'Core:BulkExport:ExportResult' => 'Result of the export:', + 'Core:BulkExport:RetrievingData' => 'Retrieving data...', + 'Core:BulkExport:HTMLFormat' => 'Web Page (*.html)', + 'Core:BulkExport:CSVFormat' => 'Comma Separated Values (*.csv)', + 'Core:BulkExport:XLSXFormat' => 'Excel 2007 or newer (*.xlsx)', + 'Core:BulkExport:PDFFormat' => 'PDF Document (*.pdf)', + 'Core:BulkExport:DragAndDropHelp' => 'Drag and drop the columns\' headers to arrange the columns. Preview of %1$s lines. Total number of lines to export: %2$s.', + 'Core:BulkExport:EmptyPreview' => 'Select the columns to be exported from the list above', + 'Core:BulkExport:ColumnsOrder' => 'Columns order', + 'Core:BulkExport:AvailableColumnsFrom_Class' => 'Available columns from %1$s', + 'Core:BulkExport:NoFieldSelected' => 'Select at least one column to be exported', + 'Core:BulkExport:CheckAll' => 'Check All', + 'Core:BulkExport:UncheckAll' => 'Uncheck All', + 'Core:BulkExport:ExportCancelledByUser' => 'Export cancelled by the user', + 'Core:BulkExport:CSVOptions' => 'CSV Options', + 'Core:BulkExport:CSVLocalization' => 'Localization', + 'Core:BulkExport:PDFOptions' => 'PDF Options', + 'Core:BulkExport:PDFPageSize' => 'Page Size:', + 'Core:BulkExport:PageSize-A4' => 'A4', + 'Core:BulkExport:PageSize-A3' => 'A3', + 'Core:BulkExport:PageSize-Letter' => 'Letter', + 'Core:BulkExport:PDFPageOrientation' => 'Page Orientation:', + 'Core:BulkExport:PageOrientation-L' => 'Landscape', + 'Core:BulkExport:PageOrientation-P' => 'Portrait', + 'Core:BulkExport:XMLFormat' => 'XML file (*.xml)', + 'Core:BulkExport:XMLOptions' => 'XML Options', + 'Core:BulkExport:SpreadsheetFormat' => 'Spreadsheet HTML format (*.html)', + 'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options', + 'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)', + 'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export', + 'Core:BulkExportLabelOQLExpression' => 'OQL Query:', + 'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:', + 'Core:BulkExportMessageEmptyOQL' => 'Please enter a valid OQL query.', + 'Core:BulkExportMessageEmptyPhrasebookEntry' => 'Please select a valid phrasebook entry.', + )); diff --git a/dictionaries/dictionary.itop.ui.php b/dictionaries/dictionary.itop.ui.php index 97e8ce3d0..de56913f9 100644 --- a/dictionaries/dictionary.itop.ui.php +++ b/dictionaries/dictionary.itop.ui.php @@ -450,7 +450,7 @@ Dict::Add('EN US', 'English', 'English', array( 'UI:Menu:Add' => 'Add...', 'UI:Menu:Manage' => 'Manage...', 'UI:Menu:EMail' => 'eMail', - 'UI:Menu:CSVExport' => 'CSV Export', + 'UI:Menu:CSVExport' => 'CSV Export...', 'UI:Menu:Modify' => 'Modify...', 'UI:Menu:Delete' => 'Delete...', 'UI:Menu:Manage' => 'Manage...', diff --git a/dictionaries/es_cr.dictionary.itop.ui.php b/dictionaries/es_cr.dictionary.itop.ui.php index b073c2dfd..df1f47463 100644 --- a/dictionaries/es_cr.dictionary.itop.ui.php +++ b/dictionaries/es_cr.dictionary.itop.ui.php @@ -447,7 +447,7 @@ Dict::Add('ES CR', 'Spanish', 'Español, Castellano', array( 'UI:Menu:Add' => 'Agregar', 'UI:Menu:Manage' => 'Administrar', 'UI:Menu:EMail' => 'Enviar por Correo Electrónico', - 'UI:Menu:CSVExport' => 'Exportar a CSV', + 'UI:Menu:CSVExport' => 'Exportar a CSV...', 'UI:Menu:Modify' => 'Modificar', 'UI:Menu:Delete' => 'Borrar', 'UI:Menu:Manage' => 'Administrar', diff --git a/dictionaries/fr.dictionary.itop.core.php b/dictionaries/fr.dictionary.itop.core.php index 6224a8805..106430408 100644 --- a/dictionaries/fr.dictionary.itop.core.php +++ b/dictionaries/fr.dictionary.itop.core.php @@ -648,5 +648,48 @@ Opérateurs :
'Core:Duration_Days_Hours_Minutes_Seconds' => '%1$sj %2$dh %3$dmin %4$ds', 'Core:ExplainWTC:ElapsedTime' => 'Temps écoulé (enregistré dans "%1$s")', 'Core:ExplainWTC:StopWatch-TimeSpent' => 'Temps écoulé pour "%1$s"', - 'Core:ExplainWTC:StopWatch-Deadline' => 'Date/heure de butée pour "%1$s" à %2$d%%' + 'Core:ExplainWTC:StopWatch-Deadline' => 'Date/heure de butée pour "%1$s" à %2$d%%', + + 'Core:BulkExport:MissingParameter_Param' => 'Il manque le paramètre "%1$s"', + 'Core:BulkExport:InvalidParameter_Query' => 'Valeur incorrecte pour le paramètre "query". Il n\'existe aucune entrée dans le livre des requêtes pour l\'identifiant: "%1$s"', + 'Core:BulkExport:ExportFormatPrompt' => 'Format d\'export:', + 'Core:BulkExport:Identifier' => 'Identifiant', + 'Core:BulkExport:Friendlyname' => 'Nom complet', + 'Core:BulkExportOf_Class' => 'Export de: %1$s', + 'Core:BulkExport:ClickHereToDownload_FileName' => 'Cliquez ici pour télécharger %1$s', + 'Core:BulkExport:ExportResult' => 'Résultat de l\'export:', + 'Core:BulkExport:RetrievingData' => 'Récupération des données...', + 'Core:BulkExport:HTMLFormat' => 'Page Web (*.html)', + 'Core:BulkExport:CSVFormat' => 'Fichier CSV (*.csv)', + 'Core:BulkExport:XLSXFormat' => 'Excel 2007 ou plus récent (*.xlsx)', + 'Core:BulkExport:PDFFormat' => 'Document PDF (*.pdf)', + 'Core:BulkExport:DragAndDropHelp' => 'Faîtes glisser les en-têtes des colonnes pour modifier leur ordre. Aperçu de %1$s lignes sur un total de %2$s lignes à exporter.', + 'Core:BulkExport:EmptyPreview' => 'Selectionnez les colonnes à exporter dans la liste ci-dessus...', + 'Core:BulkExport:ColumnsOrder' => 'Ordre des colonnes', + 'Core:BulkExport:AvailableColumnsFrom_Class' => 'Colonnes de la classe %1$s', + 'Core:BulkExport:NoFieldSelected' => 'Veuillez sélectionner au moins une colonne à exporter', + 'Core:BulkExport:CheckAll' => 'Tout cocher', + 'Core:BulkExport:UncheckAll' => 'Tout décocher', + 'Core:BulkExport:ExportCancelledByUser' => 'Export annulé par l\'utilisateur', + + 'Core:BulkExport:CSVOptions' => 'Options du format CSV', + 'Core:BulkExport:CSVLocalization' => 'Traduction', + 'Core:BulkExport:PDFOptions' => 'Options du format PDF', + 'Core:BulkExport:PDFPageSize' => 'Format de page:', + 'Core:BulkExport:PageSize-A4' => 'A4', + 'Core:BulkExport:PageSize-A3' => 'A3', + 'Core:BulkExport:PageSize-Letter' => 'Lettre (US)', + 'Core:BulkExport:PDFPageOrientation' => 'Orientation de la page:', + 'Core:BulkExport:PageOrientation-L' => 'Paysage', + 'Core:BulkExport:PageOrientation-P' => 'Portrait', + 'Core:BulkExport:XMLFormat' => 'Fichier XML (*.xml)', + 'Core:BulkExport:XMLOptions' => 'Options XML', + 'Core:BulkExport:SpreadsheetFormat' => 'Format HTML pour Excel (*.html)', + 'Core:BulkExport:SpreadsheetOptions' => 'Options du format HTML pour Excel', + 'Core:BulkExport:OptionNoLocalize' => 'Ne pas traduire les valeurs (pour les champs de type "Enum")', + 'Core:BulkExport:ScopeDefinition' => 'Définition des objets à exporter', + 'Core:BulkExportLabelOQLExpression' => 'Requête OQL:', + 'Core:BulkExportLabelPhrasebookEntry' => 'Entrée dans le livre des requêtes:', + 'Core:BulkExportMessageEmptyOQL' => 'Veuillez saisir une requête OQL valide.', + 'Core:BulkExportMessageEmptyPhrasebookEntry' => 'Veuillez sélectionner une entrée dans le livre des requêtes.', )); diff --git a/dictionaries/fr.dictionary.itop.ui.php b/dictionaries/fr.dictionary.itop.ui.php index c42808792..16a5a72a9 100644 --- a/dictionaries/fr.dictionary.itop.ui.php +++ b/dictionaries/fr.dictionary.itop.ui.php @@ -326,7 +326,7 @@ Dict::Add('FR FR', 'French', 'Français', array( 'UI:Menu:Add' => 'Ajouter...', 'UI:Menu:Manage' => 'Gérer...', 'UI:Menu:EMail' => 'Envoyer par eMail', - 'UI:Menu:CSVExport' => 'Exporter en CSV', + 'UI:Menu:CSVExport' => 'Exporter en CSV...', 'UI:Menu:Modify' => 'Modifier...', 'UI:Menu:Delete' => 'Supprimer...', 'UI:Menu:BulkDelete' => 'Supprimer...', diff --git a/dictionaries/hu.dictionary.itop.ui.php b/dictionaries/hu.dictionary.itop.ui.php index f521f7198..a1e0ef4bb 100755 --- a/dictionaries/hu.dictionary.itop.ui.php +++ b/dictionaries/hu.dictionary.itop.ui.php @@ -312,7 +312,7 @@ Dict::Add('HU HU', 'Hungarian', 'Magyar', array( 'UI:Menu:Add' => 'Hozzáad...', 'UI:Menu:Manage' => 'Kezel...', 'UI:Menu:EMail' => 'e-mail', - 'UI:Menu:CSVExport' => 'CSV export', + 'UI:Menu:CSVExport' => 'CSV export...', 'UI:Menu:Modify' => 'Módosít...', 'UI:Menu:Delete' => 'Töröl...', 'UI:Menu:BulkDelete' => 'Töröl...', diff --git a/dictionaries/it.dictionary.itop.ui.php b/dictionaries/it.dictionary.itop.ui.php index db6339d1e..5198b1443 100644 --- a/dictionaries/it.dictionary.itop.ui.php +++ b/dictionaries/it.dictionary.itop.ui.php @@ -443,7 +443,7 @@ Dict::Add('IT IT', 'Italian', 'Italiano', array( 'UI:Menu:Add' => 'Aggiungi...', 'UI:Menu:Manage' => 'Gestischi...', 'UI:Menu:EMail' => 'eMail', - 'UI:Menu:CSVExport' => 'CSV Export', + 'UI:Menu:CSVExport' => 'CSV Export...', 'UI:Menu:Modify' => 'Modifica...', 'UI:Menu:Delete' => 'Cancella...', 'UI:Menu:Manage' => 'Gestisci...', diff --git a/dictionaries/ja.dictionary.itop.ui.php b/dictionaries/ja.dictionary.itop.ui.php index 688c03ec7..016eaba4e 100644 --- a/dictionaries/ja.dictionary.itop.ui.php +++ b/dictionaries/ja.dictionary.itop.ui.php @@ -328,7 +328,7 @@ Dict::Add('JA JP', 'Japanese', '日本語', array( 'UI:Menu:Add' => '追加...', 'UI:Menu:Manage' => '管理...', 'UI:Menu:EMail' => 'Eメール', - 'UI:Menu:CSVExport' => 'CSVエクスポート', + 'UI:Menu:CSVExport' => 'CSVエクスポート...', 'UI:Menu:Modify' => '修正...', 'UI:Menu:Delete' => '削除...', 'UI:Menu:BulkDelete' => '削除...', diff --git a/dictionaries/nl.dictionary.itop.ui.php b/dictionaries/nl.dictionary.itop.ui.php index fff8668ee..022b12e10 100644 --- a/dictionaries/nl.dictionary.itop.ui.php +++ b/dictionaries/nl.dictionary.itop.ui.php @@ -453,7 +453,7 @@ Dict::Add('NL NL', 'Dutch', 'Nederlands', array( 'UI:Menu:Add' => 'Voeg toe...', 'UI:Menu:Manage' => 'Manage...', 'UI:Menu:EMail' => 'eMail', - 'UI:Menu:CSVExport' => 'CSV Export', + 'UI:Menu:CSVExport' => 'CSV Export...', 'UI:Menu:Modify' => 'Bewerk...', 'UI:Menu:Delete' => 'Verwijder...', 'UI:Menu:Manage' => 'Manage...', diff --git a/dictionaries/pt_br.dictionary.itop.ui.php b/dictionaries/pt_br.dictionary.itop.ui.php index 8ad0d7d0d..9c2c0e49a 100644 --- a/dictionaries/pt_br.dictionary.itop.ui.php +++ b/dictionaries/pt_br.dictionary.itop.ui.php @@ -446,7 +446,7 @@ Dict::Add('PT BR', 'Brazilian', 'Brazilian', array( 'UI:Menu:Add' => 'Adicionar...', 'UI:Menu:Manage' => 'Gerenciar...', 'UI:Menu:EMail' => 'eMail', - 'UI:Menu:CSVExport' => 'Exportar CSV', + 'UI:Menu:CSVExport' => 'Exportar CSV...', 'UI:Menu:Modify' => 'Modificar...', 'UI:Menu:Delete' => 'Excluir...', 'UI:Menu:Manage' => 'Gerenciar...', diff --git a/dictionaries/ru.dictionary.itop.ui.php b/dictionaries/ru.dictionary.itop.ui.php index d0285daee..972f2cf38 100644 --- a/dictionaries/ru.dictionary.itop.ui.php +++ b/dictionaries/ru.dictionary.itop.ui.php @@ -442,7 +442,7 @@ Dict::Add('RU RU', 'Russian', 'Русский', array( 'UI:Menu:Add' => 'Добавить...', 'UI:Menu:Manage' => 'Управление...', 'UI:Menu:EMail' => 'Отправить ссылку по email', - 'UI:Menu:CSVExport' => 'Экспорт в CSV', + 'UI:Menu:CSVExport' => 'Экспорт в CSV...', 'UI:Menu:Modify' => 'Изменить...', 'UI:Menu:Delete' => 'Удалить...', 'UI:Menu:Manage' => 'Управление...', diff --git a/dictionaries/tr.dictionary.itop.ui.php b/dictionaries/tr.dictionary.itop.ui.php index af73daf80..8fc027824 100644 --- a/dictionaries/tr.dictionary.itop.ui.php +++ b/dictionaries/tr.dictionary.itop.ui.php @@ -416,7 +416,7 @@ Dict::Add('TR TR', 'Turkish', 'Türkçe', array( 'UI:Menu:Add' => 'Ekle...', 'UI:Menu:Manage' => 'Yönet...', 'UI:Menu:EMail' => 'e-posta', - 'UI:Menu:CSVExport' => 'CSV olarak dışarı ver', + 'UI:Menu:CSVExport' => 'CSV olarak dışarı ver...', 'UI:Menu:Modify' => 'Düzenle...', 'UI:Menu:Delete' => 'Sil...', 'UI:Menu:Manage' => 'Yönet...', diff --git a/dictionaries/zh.dictionary.itop.ui.php b/dictionaries/zh.dictionary.itop.ui.php index 74c3b1165..f711dbe9d 100644 --- a/dictionaries/zh.dictionary.itop.ui.php +++ b/dictionaries/zh.dictionary.itop.ui.php @@ -415,7 +415,7 @@ Dict::Add('ZH CN', 'Chinese', '简体中文', array( 'UI:Menu:Add' => '添加...', 'UI:Menu:Manage' => '管理...', 'UI:Menu:EMail' => 'eMail', - 'UI:Menu:CSVExport' => 'CSV 导出', + 'UI:Menu:CSVExport' => 'CSV 导出...', 'UI:Menu:Modify' => '修改...', 'UI:Menu:Delete' => '删除...', 'UI:Menu:Manage' => '管理...', diff --git a/js/jquery.dragtable.js b/js/jquery.dragtable.js new file mode 100644 index 000000000..9595eb731 --- /dev/null +++ b/js/jquery.dragtable.js @@ -0,0 +1,401 @@ +/*! + * dragtable + * + * @Version 2.0.14 + * + * Copyright (c) 2010-2013, Andres akottr@gmail.com + * Dual licensed under the MIT (MIT-LICENSE.txt) + * and GPL (GPL-LICENSE.txt) licenses. + * + * Inspired by the the dragtable from Dan Vanderkam (danvk.org/dragtable/) + * Thanks to the jquery and jqueryui comitters + * + * Any comment, bug report, feature-request is welcome + * Feel free to contact me. + */ + +/* TOKNOW: + * For IE7 you need this css rule: + * table { + * border-collapse: collapse; + * } + * Or take a clean reset.css (see http://meyerweb.com/eric/tools/css/reset/) + */ + +/* TODO: investigate + * Does not work properly with css rule: + * html { + * overflow: -moz-scrollbars-vertical; + * } + * Workaround: + * Fixing Firefox issues by scrolling down the page + * http://stackoverflow.com/questions/2451528/jquery-ui-sortable-scroll-helper-element-offset-firefox-issue + * + * var start = $.noop; + * var beforeStop = $.noop; + * if($.browser.mozilla) { + * var start = function (event, ui) { + * if( ui.helper !== undefined ) + * ui.helper.css('position','absolute').css('margin-top', $(window).scrollTop() ); + * } + * var beforeStop = function (event, ui) { + * if( ui.offset !== undefined ) + * ui.helper.css('margin-top', 0); + * } + * } + * + * and pass this as start and stop function to the sortable initialisation + * start: start, + * beforeStop: beforeStop + */ +/* + * Special thx to all pull requests comitters + */ + +(function($) { + $.widget("akottr.dragtable", { + options: { + revert: false, // smooth revert + dragHandle: '.table-handle', // handle for moving cols, if not exists the whole 'th' is the handle + maxMovingRows: 40, // 1 -> only header. 40 row should be enough, the rest is usually not in the viewport + excludeFooter: false, // excludes the footer row(s) while moving other columns. Make sense if there is a footer with a colspan. */ + onlyHeaderThreshold: 100, // TODO: not implemented yet, switch automatically between entire col moving / only header moving + dragaccept: null, // draggable cols -> default all + persistState: null, // url or function -> plug in your custom persistState function right here. function call is persistState(originalTable) + restoreState: null, // JSON-Object or function: some kind of experimental aka Quick-Hack TODO: do it better + exact: true, // removes pixels, so that the overlay table width fits exactly the original table width + clickDelay: 10, // ms to wait before rendering sortable list and delegating click event + containment: null, // @see http://api.jqueryui.com/sortable/#option-containment, use it if you want to move in 2 dimesnions (together with axis: null) + cursor: 'move', // @see http://api.jqueryui.com/sortable/#option-cursor + cursorAt: false, // @see http://api.jqueryui.com/sortable/#option-cursorAt + distance: 0, // @see http://api.jqueryui.com/sortable/#option-distance, for immediate feedback use "0" + tolerance: 'pointer', // @see http://api.jqueryui.com/sortable/#option-tolerance + axis: 'x', // @see http://api.jqueryui.com/sortable/#option-axis, Only vertical moving is allowed. Use 'x' or null. Use this in conjunction with the 'containment' setting + beforeStart: $.noop, // returning FALSE will stop the execution chain. + beforeMoving: $.noop, + beforeReorganize: $.noop, + beforeStop: $.noop + }, + originalTable: { + el: null, + selectedHandle: null, + sortOrder: null, + startIndex: 0, + endIndex: 0 + }, + sortableTable: { + el: $(), + selectedHandle: $(), + movingRow: $() + }, + persistState: function() { + var _this = this; + this.originalTable.el.find('th').each(function(i) { + if (this.id !== '') { + _this.originalTable.sortOrder[this.id] = i; + } + }); + $.ajax({ + url: this.options.persistState, + data: this.originalTable.sortOrder + }); + }, + /* + * persistObj looks like + * {'id1':'2','id3':'3','id2':'1'} + * table looks like + * | id2 | id1 | id3 | + */ + _restoreState: function(persistObj) { + for (var n in persistObj) { + this.originalTable.startIndex = $('#' + n).closest('th').prevAll().size() + 1; + this.originalTable.endIndex = parseInt(persistObj[n], 10) + 1; + this._bubbleCols(); + } + }, + // bubble the moved col left or right + _bubbleCols: function() { + var i, j, col1, col2; + var from = this.originalTable.startIndex; + var to = this.originalTable.endIndex; + /* Find children thead and tbody. + * Only to process the immediate tr-children. Bugfix for inner tables + */ + var thtb = this.originalTable.el.children(); + if (this.options.excludeFooter) { + thtb = thtb.not('tfoot'); + } + if (from < to) { + for (i = from; i < to; i++) { + col1 = thtb.find('> tr > td:nth-child(' + i + ')') + .add(thtb.find('> tr > th:nth-child(' + i + ')')); + col2 = thtb.find('> tr > td:nth-child(' + (i + 1) + ')') + .add(thtb.find('> tr > th:nth-child(' + (i + 1) + ')')); + for (j = 0; j < col1.length; j++) { + swapNodes(col1[j], col2[j]); + } + } + } else { + for (i = from; i > to; i--) { + col1 = thtb.find('> tr > td:nth-child(' + i + ')') + .add(thtb.find('> tr > th:nth-child(' + i + ')')); + col2 = thtb.find('> tr > td:nth-child(' + (i - 1) + ')') + .add(thtb.find('> tr > th:nth-child(' + (i - 1) + ')')); + for (j = 0; j < col1.length; j++) { + swapNodes(col1[j], col2[j]); + } + } + } + }, + _rearrangeTableBackroundProcessing: function() { + var _this = this; + return function() { + _this._bubbleCols(); + _this.options.beforeStop(_this.originalTable); + _this.sortableTable.el.remove(); + restoreTextSelection(); + // persist state if necessary + if (_this.options.persistState !== null) { + $.isFunction(_this.options.persistState) ? _this.options.persistState(_this.originalTable) : _this.persistState(); + } + }; + }, + _rearrangeTable: function() { + var _this = this; + return function() { + // remove handler-class -> handler is now finished + _this.originalTable.selectedHandle.removeClass('dragtable-handle-selected'); + // add disabled class -> reorgorganisation starts soon + _this.sortableTable.el.sortable("disable"); + _this.sortableTable.el.addClass('dragtable-disabled'); + _this.options.beforeReorganize(_this.originalTable, _this.sortableTable); + // do reorganisation asynchronous + // for chrome a little bit more than 1 ms because we want to force a rerender + _this.originalTable.endIndex = _this.sortableTable.movingRow.prevAll().size() + 1; + setTimeout(_this._rearrangeTableBackroundProcessing(), 50); + }; + }, + /* + * Disrupts the table. The original table stays the same. + * But on a layer above the original table we are constructing a list (ul > li) + * each li with a separate table representig a single col of the original table. + */ + _generateSortable: function(e) { + !e.cancelBubble && (e.cancelBubble = true); + var _this = this; + // table attributes + var attrs = this.originalTable.el[0].attributes; + var attrsString = ''; + for (var i = 0; i < attrs.length; i++) { + if (attrs[i].nodeValue && attrs[i].nodeName != 'id' && attrs[i].nodeName != 'width') { + attrsString += attrs[i].nodeName + '="' + attrs[i].nodeValue + '" '; + } + } + + // row attributes + var rowAttrsArr = []; + //compute height, special handling for ie needed :-( + var heightArr = []; + this.originalTable.el.find('tr').slice(0, this.options.maxMovingRows).each(function(i, v) { + // row attributes + var attrs = this.attributes; + var attrsString = ""; + for (var j = 0; j < attrs.length; j++) { + if (attrs[j].nodeValue && attrs[j].nodeName != 'id') { + attrsString += " " + attrs[j].nodeName + '="' + attrs[j].nodeValue + '"'; + } + } + rowAttrsArr.push(attrsString); + heightArr.push($(this).height()); + }); + + // compute width, no special handling for ie needed :-) + var widthArr = []; + // compute total width, needed for not wrapping around after the screen ends (floating) + var totalWidth = 0; + /* Find children thead and tbody. + * Only to process the immediate tr-children. Bugfix for inner tables + */ + var thtb = _this.originalTable.el.children(); + if (this.options.excludeFooter) { + thtb = thtb.not('tfoot'); + } + thtb.find('> tr > th').each(function(i, v) { + var w = $(this).outerWidth(); + widthArr.push(w); + totalWidth += w; + }); + if(_this.options.exact) { + var difference = totalWidth - _this.originalTable.el.outerWidth(); + widthArr[0] -= difference; + } + // one extra px on right and left side + totalWidth += 2 + + var sortableHtml = '