From 3b749c2177c592d1c5a56774b803d4df0a03dcc6 Mon Sep 17 00:00:00 2001 From: joelsalisbury Date: Wed, 26 Oct 2016 10:07:25 -0400 Subject: [PATCH] just need a spot to keep this --- CsvImportPlugin.php | 245 ++++++ controllers/IndexController.php | 392 +++++++++ csv_files/test.csv | 4 + .../test_automap_columns_to_elements.csv | 4 + forms/Main.php | 453 ++++++++++ forms/Mapping.php | 321 ++++++++ languages/bg_BG.mo | Bin 0 -> 12978 bytes languages/ca_ES.mo | Bin 0 -> 10339 bytes languages/cs.mo | Bin 0 -> 3730 bytes languages/de_DE.mo | Bin 0 -> 10238 bytes languages/es.mo | Bin 0 -> 10218 bytes languages/et.mo | Bin 0 -> 5874 bytes languages/fi_FI.mo | Bin 0 -> 9599 bytes languages/fr.mo | Bin 0 -> 10194 bytes languages/hr.mo | Bin 0 -> 9859 bytes languages/it.mo | Bin 0 -> 1945 bytes languages/mn.mo | Bin 0 -> 2495 bytes languages/nl_NL.mo | Bin 0 -> 9961 bytes languages/pl.mo | Bin 0 -> 1083 bytes languages/pt_BR.mo | Bin 0 -> 9981 bytes languages/pt_PT.mo | Bin 0 -> 10141 bytes languages/ro.mo | Bin 0 -> 9995 bytes languages/sr_RS.mo | Bin 0 -> 13306 bytes models/CsvImport/ColumnMap.php | 51 ++ models/CsvImport/ColumnMap/Collection.php | 69 ++ models/CsvImport/ColumnMap/Element.php | 119 +++ .../CsvImport/ColumnMap/ExportedElement.php | 145 ++++ models/CsvImport/ColumnMap/Featured.php | 39 + models/CsvImport/ColumnMap/File.php | 80 ++ models/CsvImport/ColumnMap/ItemType.php | 33 + models/CsvImport/ColumnMap/Public.php | 39 + models/CsvImport/ColumnMap/Set.php | 56 ++ models/CsvImport/ColumnMap/Tag.php | 75 ++ models/CsvImport/DuplicateColumnException.php | 10 + models/CsvImport/File.php | 119 +++ models/CsvImport/Import.php | 774 ++++++++++++++++++ models/CsvImport/ImportTask.php | 103 +++ models/CsvImport/ImportedItem.php | 34 + models/CsvImport/MissingColumnException.php | 10 + models/CsvImport/RowIterator.php | 286 +++++++ models/Table/CsvImport/Import.php | 11 + plugin.ini | 11 + views/admin/common/csvimport-nav.php | 17 + views/admin/css/csv-import-main.css | 58 ++ views/admin/index/browse.php | 84 ++ views/admin/index/check-omeka-csv.php | 19 + views/admin/index/index.php | 20 + views/admin/index/map-columns-form.php | 31 + views/admin/index/map-columns.php | 19 + views/admin/javascripts/csv-import.js | 51 ++ 50 files changed, 3782 insertions(+) create mode 100644 CsvImportPlugin.php create mode 100644 controllers/IndexController.php create mode 100644 csv_files/test.csv create mode 100644 csv_files/test_automap_columns_to_elements.csv create mode 100644 forms/Main.php create mode 100644 forms/Mapping.php create mode 100644 languages/bg_BG.mo create mode 100644 languages/ca_ES.mo create mode 100644 languages/cs.mo create mode 100644 languages/de_DE.mo create mode 100644 languages/es.mo create mode 100644 languages/et.mo create mode 100644 languages/fi_FI.mo create mode 100644 languages/fr.mo create mode 100644 languages/hr.mo create mode 100644 languages/it.mo create mode 100644 languages/mn.mo create mode 100644 languages/nl_NL.mo create mode 100644 languages/pl.mo create mode 100644 languages/pt_BR.mo create mode 100644 languages/pt_PT.mo create mode 100644 languages/ro.mo create mode 100644 languages/sr_RS.mo create mode 100644 models/CsvImport/ColumnMap.php create mode 100644 models/CsvImport/ColumnMap/Collection.php create mode 100644 models/CsvImport/ColumnMap/Element.php create mode 100644 models/CsvImport/ColumnMap/ExportedElement.php create mode 100644 models/CsvImport/ColumnMap/Featured.php create mode 100644 models/CsvImport/ColumnMap/File.php create mode 100644 models/CsvImport/ColumnMap/ItemType.php create mode 100644 models/CsvImport/ColumnMap/Public.php create mode 100644 models/CsvImport/ColumnMap/Set.php create mode 100644 models/CsvImport/ColumnMap/Tag.php create mode 100644 models/CsvImport/DuplicateColumnException.php create mode 100644 models/CsvImport/File.php create mode 100644 models/CsvImport/Import.php create mode 100644 models/CsvImport/ImportTask.php create mode 100644 models/CsvImport/ImportedItem.php create mode 100644 models/CsvImport/MissingColumnException.php create mode 100644 models/CsvImport/RowIterator.php create mode 100644 models/Table/CsvImport/Import.php create mode 100644 plugin.ini create mode 100644 views/admin/common/csvimport-nav.php create mode 100644 views/admin/css/csv-import-main.css create mode 100644 views/admin/index/browse.php create mode 100644 views/admin/index/check-omeka-csv.php create mode 100644 views/admin/index/index.php create mode 100644 views/admin/index/map-columns-form.php create mode 100644 views/admin/index/map-columns.php create mode 100644 views/admin/javascripts/csv-import.js diff --git a/CsvImportPlugin.php b/CsvImportPlugin.php new file mode 100644 index 0000000..0ef398a --- /dev/null +++ b/CsvImportPlugin.php @@ -0,0 +1,245 @@ + +* plugins.CsvImport.columnDelimiter = "," +* plugins.CsvImport.memoryLimit = "128M" +* plugins.CsvImport.requiredExtension = "txt" +* plugins.CsvImport.requiredMimeType = "text/csv" +* plugins.CsvImport.maxFileSize = "10M" +* plugins.CsvImport.fileDestination = "/tmp" +* plugins.CsvImport.batchSize = "1000" +* +* +* All of the above settings are optional. If not given, CsvImport uses the +* following default values: +* +* memoryLimit = current script limit +* requiredExtension = "txt" or "csv" +* requiredMimeType = "text/csv" +* maxFileSize = current system upload limit +* fileDestination = current system temporary dir (via sys_get_temp_dir()) +* batchSize = 0 (no batching) +* +* +* Set a high memory limit to avoid memory allocation issues with imports. +* Examples include 128M, 1G, and -1. This will set PHP's memory_limit setting +* directly, see PHP's documentation for more info on formatting this number. +* Be advised that many web hosts set a maximum memory limit, so this setting +* may be ignored if it exceeds the maximum allowable limit. Check with your web +* host for more information. +* +* Note that 'maxFileSize' will not affect post_max_size or upload_max_filesize +* as is set in php.ini. Having a maxFileSize that exceeds either +* will still result in errors that prevent the file upload. +* +* batchSize: Setting for advanced users. If you find that your long-running +* imports are using too much memory or otherwise hogging system resources, +* set this value to split your import into multiple jobs based on the +* number of CSV rows to process per job. +* +* For example, if you have a CSV with 150000 rows, setting a batchSize +* of 5000 would cause the import to be split up over 30 separate jobs. +* Note that these jobs run sequentially based on the results of prior +* jobs, meaning that the import cannot be parallelized. The first job +* will import 5000 rows and then spawn the next job, and so on until +* the import is completed. +* +* +* @copyright Copyright 2008-2012 Roy Rosenzweig Center for History and New Media +* @license http://www.gnu.org/licenses/gpl-3.0.txt GNU GPLv3 +* @package CsvImport +*/ + +defined('CSV_IMPORT_DIRECTORY') or define('CSV_IMPORT_DIRECTORY', dirname(__FILE__)); + +/** + * Csv Import plugin. + */ +class CsvImportPlugin extends Omeka_Plugin_AbstractPlugin +{ + const MEMORY_LIMIT_OPTION_NAME = 'csv_import_memory_limit'; + const PHP_PATH_OPTION_NAME = 'csv_import_php_path'; + + /** + * @var array Hooks for the plugin. + */ + protected $_hooks = array( + 'install', + 'uninstall', + 'upgrade', + 'initialize', + 'admin_head', + 'define_acl', + ); + + /** + * @var array Filters for the plugin. + */ + protected $_filters = array( + 'admin_navigation_main', + ); + + /** + * @var array Options and their default values. + */ + protected $_options = array( + self::MEMORY_LIMIT_OPTION_NAME => '', + self::PHP_PATH_OPTION_NAME => '', + CsvImport_RowIterator::COLUMN_DELIMITER_OPTION_NAME => CsvImport_RowIterator::DEFAULT_COLUMN_DELIMITER, + CsvImport_ColumnMap_Element::ELEMENT_DELIMITER_OPTION_NAME => CsvImport_ColumnMap_Element::DEFAULT_ELEMENT_DELIMITER, + CsvImport_ColumnMap_Tag::TAG_DELIMITER_OPTION_NAME => CsvImport_ColumnMap_Tag::DEFAULT_TAG_DELIMITER, + CsvImport_ColumnMap_File::FILE_DELIMITER_OPTION_NAME => CsvImport_ColumnMap_File::DEFAULT_FILE_DELIMITER, + ); + + /** + * Install the plugin. + */ + public function hookInstall() + { + $db = $this->_db; + + // create csv imports table + $db->query("CREATE TABLE IF NOT EXISTS `{$db->prefix}csv_import_imports` ( + `id` int(10) unsigned NOT NULL auto_increment, + `item_type_id` int(10) unsigned NULL, + `collection_id` int(10) unsigned NULL, + `owner_id` int unsigned NOT NULL, + `delimiter` varchar(1) collate utf8_unicode_ci NOT NULL, + `original_filename` text collate utf8_unicode_ci NOT NULL, + `file_path` text collate utf8_unicode_ci NOT NULL, + `file_position` bigint unsigned NOT NULL, + `status` varchar(255) collate utf8_unicode_ci, + `skipped_row_count` int(10) unsigned NOT NULL, + `skipped_item_count` int(10) unsigned NOT NULL, + `is_public` tinyint(1) default '0', + `is_featured` tinyint(1) default '0', + `serialized_column_maps` text collate utf8_unicode_ci NOT NULL, + `added` timestamp NOT NULL default '2000-01-01 00:00:00', + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;"); + + // create csv imported items table + $db->query("CREATE TABLE IF NOT EXISTS `{$db->prefix}csv_import_imported_items` ( + `id` int(10) unsigned NOT NULL auto_increment, + `item_id` int(10) unsigned NOT NULL, + `import_id` int(10) unsigned NOT NULL, + PRIMARY KEY (`id`), + KEY (`import_id`), + UNIQUE (`item_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;"); + + $this->_installOptions(); + } + + /** + * Uninstall the plugin. + */ + public function hookUninstall() + { + $db = $this->_db; + + // drop the tables + $sql = "DROP TABLE IF EXISTS `{$db->prefix}csv_import_imports`"; + $db->query($sql); + $sql = "DROP TABLE IF EXISTS `{$db->prefix}csv_import_imported_items`"; + $db->query($sql); + + $this->_uninstallOptions(); + } + + /** + * Upgrade the plugin. + */ + public function hookUpgrade($args) + { + $oldVersion = $args['old_version']; + $newVersion = $args['new_version']; + $db = $this->_db; + + // Do this first because MySQL will complain about any ALTERs to a table with an + // invalid default if we don't fix it first + if (version_compare($oldVersion, '2.0.3', '<=')) { + $sql = "ALTER TABLE `{$db->prefix}csv_import_imports` MODIFY `added` timestamp NOT NULL default '2000-01-01 00:00:00'"; + $db->query($sql); + } + + if (version_compare($oldVersion, '2.0-dev', '<=')) { + $sql = "UPDATE `{$db->prefix}csv_import_imports` SET `status` = ? WHERE `status` = ?"; + $db->query($sql, array('other_error', 'error')); + } + + if (version_compare($oldVersion, '2.0', '<=')) { + set_option(CsvImport_RowIterator::COLUMN_DELIMITER_OPTION_NAME, CsvImport_RowIterator::DEFAULT_COLUMN_DELIMITER); + set_option(CsvImport_ColumnMap_Element::ELEMENT_DELIMITER_OPTION_NAME, CsvImport_ColumnMap_Element::DEFAULT_ELEMENT_DELIMITER); + set_option(CsvImport_ColumnMap_Tag::TAG_DELIMITER_OPTION_NAME, CsvImport_ColumnMap_Tag::DEFAULT_TAG_DELIMITER); + set_option(CsvImport_ColumnMap_File::FILE_DELIMITER_OPTION_NAME, CsvImport_ColumnMap_File::DEFAULT_FILE_DELIMITER); + } + + if(version_compare($oldVersion, '2.0.1', '<=')) { + $sql = "ALTER TABLE `{$db->prefix}csv_import_imports` CHANGE `item_type_id` `item_type_id` INT( 10 ) UNSIGNED NULL , + CHANGE `collection_id` `collection_id` INT( 10 ) UNSIGNED NULL + "; + $db->query($sql); + } + } + + /** + * Add the translations. + */ + public function hookInitialize() + { + add_translation_source(dirname(__FILE__) . '/languages'); + } + + /** + * Define the ACL. + * + * @param array $args + */ + public function hookDefineAcl($args) + { + $acl = $args['acl']; // get the Zend_Acl + + $acl->addResource('CsvImport_Index'); + + // Hack to disable CRUD actions. + $acl->deny(null, 'CsvImport_Index', array('show', 'add', 'edit', 'delete')); + $acl->deny('admin', 'CsvImport_Index'); + } + + /** + * Configure admin theme header. + * + * @param array $args + */ + public function hookAdminHead($args) + { + $request = Zend_Controller_Front::getInstance()->getRequest(); + if ($request->getModuleName() == 'csv-import') { + queue_css_file('csv-import-main'); + queue_js_file('csv-import'); + } + } + + /** + * Add the Simple Pages link to the admin main navigation. + * + * @param array Navigation array. + * @return array Filtered navigation array. + */ + public function filterAdminNavigationMain($nav) + { + $nav[] = array( + 'label' => __('CSV Import'), + 'uri' => url('csv-import'), + 'resource' => 'CsvImport_Index', + 'privilege' => 'index', + ); + return $nav; + } +} diff --git a/controllers/IndexController.php b/controllers/IndexController.php new file mode 100644 index 0000000..f5be898 --- /dev/null +++ b/controllers/IndexController.php @@ -0,0 +1,392 @@ +session = new Zend_Session_Namespace('CsvImport'); + $this->_helper->db->setDefaultModelName('CsvImport_Import'); + } + + /** + * Configure a new import. + */ + public function indexAction() + { + $form = $this->_getMainForm(); + $this->view->form = $form; + + if (!$this->getRequest()->isPost()) { + return; + } + + if (!$form->isValid($this->getRequest()->getPost())) { + $this->_helper->flashMessenger(__('Invalid form input. Please see errors below and try again.'), 'error'); + return; + } + + if (!$form->csv_file->receive()) { + $this->_helper->flashMessenger(__('Error uploading file. Please try again.'), 'error'); + return; + } + + $filePath = $form->csv_file->getFileName(); + $columnDelimiter = $form->getValue('column_delimiter'); + + $file = new CsvImport_File($filePath, $columnDelimiter); + + if (!$file->parse()) { + $this->_helper->flashMessenger(__('Your file is incorrectly formatted.') + . ' ' . $file->getErrorString(), 'error'); + return; + } + + $this->session->setExpirationHops(2); + $this->session->originalFilename = $_FILES['csv_file']['name']; + $this->session->filePath = $filePath; + + $this->session->columnDelimiter = $columnDelimiter; + $this->session->columnNames = $file->getColumnNames(); + $this->session->columnExamples = $file->getColumnExamples(); + + $this->session->fileDelimiter = $form->getValue('file_delimiter'); + $this->session->tagDelimiter = $form->getValue('tag_delimiter'); + $this->session->elementDelimiter = $form->getValue('element_delimiter'); + $this->session->itemTypeId = $form->getValue('item_type_id'); + $this->session->itemsArePublic = $form->getValue('items_are_public'); + $this->session->itemsAreFeatured = $form->getValue('items_are_featured'); + $this->session->collectionId = $form->getValue('collection_id'); + + $this->session->automapColumnNamesToElements = $form->getValue('automap_columns_names_to_elements'); + + $this->session->ownerId = $this->getInvokeArg('bootstrap')->currentuser->id; + + // All is valid, so we save settings. + set_option(CsvImport_RowIterator::COLUMN_DELIMITER_OPTION_NAME, $this->session->columnDelimiter); + set_option(CsvImport_ColumnMap_Element::ELEMENT_DELIMITER_OPTION_NAME, $this->session->elementDelimiter); + set_option(CsvImport_ColumnMap_Tag::TAG_DELIMITER_OPTION_NAME, $this->session->tagDelimiter); + set_option(CsvImport_ColumnMap_File::FILE_DELIMITER_OPTION_NAME, $this->session->fileDelimiter); + + if ($form->getValue('omeka_csv_export')) { + $this->_helper->redirector->goto('check-omeka-csv'); + } + + $this->_helper->redirector->goto('map-columns'); + } + + /** + * Map the columns for an import. + */ + public function mapColumnsAction() + { + if (!$this->_sessionIsValid()) { + $this->_helper->flashMessenger(__('Import settings expired. Please try again.'), 'error'); + $this->_helper->redirector->goto('index'); + return; + } + + require_once CSV_IMPORT_DIRECTORY . '/forms/Mapping.php'; + $form = new CsvImport_Form_Mapping(array( + 'itemTypeId' => $this->session->itemTypeId, + 'columnNames' => $this->session->columnNames, + 'columnExamples' => $this->session->columnExamples, + 'fileDelimiter' => $this->session->fileDelimiter, + 'tagDelimiter' => $this->session->tagDelimiter, + 'elementDelimiter' => $this->session->elementDelimiter, + 'automapColumnNamesToElements' => $this->session->automapColumnNamesToElements + )); + $this->view->form = $form; + + if (!$this->getRequest()->isPost()) { + return; + } + if (!$form->isValid($this->getRequest()->getPost())) { + $this->_helper->flashMessenger(__('Invalid form input. Please try again.'), 'error'); + return; + } + + $columnMaps = $form->getColumnMaps(); + if (count($columnMaps) == 0) { + $this->_helper->flashMessenger(__('Please map at least one column to an element, file, or tag.'), 'error'); + return; + } + + $csvImport = new CsvImport_Import(); + foreach ($this->session->getIterator() as $key => $value) { + $setMethod = 'set' . ucwords($key); + if (method_exists($csvImport, $setMethod)) { + $csvImport->$setMethod($value); + } + } + $csvImport->setColumnMaps($columnMaps); + if ($csvImport->queue()) { + $this->_dispatchImportTask($csvImport, CsvImport_ImportTask::METHOD_START); + $this->_helper->flashMessenger(__('Import started. Reload this page for status updates.'), 'success'); + } else { + $this->_helper->flashMessenger(__('Import could not be started. Please check error logs for more details.'), 'error'); + } + + $this->session->unsetAll(); + $this->_helper->redirector->goto('browse'); + } + + /** + * For import of Omeka.net CSV. + * Check if all needed Elements are present. + */ + public function checkOmekaCsvAction() + { + $elementTable = get_db()->getTable('Element'); + $skipColumns = array('itemType', + 'collection', + 'tags', + 'public', + 'featured', + 'file'); + + $skipColumnsWrapped = array(); + foreach($skipColumns as $skipColumn) { + $skipColumnsWrapped[] = "'" . $skipColumn . "'"; + } + $skipColumnsText = '( ' . implode(', ', $skipColumnsWrapped) . ' )'; + + if (empty($this->session->columnNames)) { + $this->_helper->redirector->goto('index'); + } + + $hasError = false; + foreach ($this->session->columnNames as $columnName){ + if (!in_array($columnName, $skipColumns)) { + $data = explode(':', $columnName); + if (count($data) != 2) { + $this->_helper->flashMessenger(__('Invalid column names. Column names must either be one of the following %s, or have the following format: {ElementSetName}:{ElementName}', $skipColumnsText), 'error'); + $hasError = true; + break; + } + } + } + + if (!$hasError) { + foreach ($this->session->columnNames as $columnName){ + if (!in_array($columnName, $skipColumns)) { + $data = explode(':', $columnName); + //$data is like array('Element Set Name', 'Element Name'); + $elementSetName = $data[0]; + $elementName = $data[1]; + $element = $elementTable->findByElementSetNameAndElementName($elementSetName, $elementName); + if (empty($element)) { + $this->_helper->flashMessenger(__('Element "%s" is not found in element set "%s"', array($elementName, $elementSetName)), 'error'); + $hasError = true; + } + } + } + } + + if (!$hasError) { + $this->_helper->redirector->goto('omeka-csv'); + } + } + + /** + * Create and queue a new import from Omeka.net. + */ + public function omekaCsvAction() + { + // specify the export format's file and tag delimiters + // do not allow the user to specify it + $fileDelimiter = ','; + $tagDelimiter = ','; + + $headings = $this->session->columnNames; + $columnMaps = array(); + foreach ($headings as $heading) { + switch ($heading) { + case 'collection': + $columnMaps[] = new CsvImport_ColumnMap_Collection($heading); + break; + case 'itemType': + $columnMaps[] = new CsvImport_ColumnMap_ItemType($heading); + break; + case 'file': + $columnMaps[] = new CsvImport_ColumnMap_File($heading, $fileDelimiter); + break; + case 'tags': + $columnMaps[] = new CsvImport_ColumnMap_Tag($heading, $tagDelimiter); + break; + case 'public': + $columnMaps[] = new CsvImport_ColumnMap_Public($heading); + break; + case 'featured': + $columnMaps[] = new CsvImport_ColumnMap_Featured($heading); + break; + default: + $columnMaps[] = new CsvImport_ColumnMap_ExportedElement($heading); + break; + } + } + $csvImport = new CsvImport_Import(); + + //this is the clever way that mapColumns action sets the values passed along from indexAction + //many will be irrelevant here, since CsvImport allows variable itemTypes and Collection + + //@TODO: check if variable itemTypes and Collections breaks undo. It probably should, actually + foreach ($this->session->getIterator() as $key => $value) { + $setMethod = 'set' . ucwords($key); + if (method_exists($csvImport, $setMethod)) { + $csvImport->$setMethod($value); + } + } + $csvImport->setColumnMaps($columnMaps); + if ($csvImport->queue()) { + $this->_dispatchImportTask($csvImport, CsvImport_ImportTask::METHOD_START); + $this->_helper->flashMessenger(__('Import started. Reload this page for status updates.'), 'success'); + } else { + $this->_helper->flashMessenger(__('Import could not be started. Please check error logs for more details.'), 'error'); + } + $this->session->unsetAll(); + $this->_helper->redirector->goto('browse'); + } + + /** + * Browse the imports. + */ + public function browseAction() + { + if (!$this->_getParam('sort_field')) { + $this->_setParam('sort_field', 'added'); + $this->_setParam('sort_dir', 'd'); + } + parent::browseAction(); + } + + /** + * Undo the import. + */ + public function undoImportAction() + { + $csvImport = $this->_helper->db->findById(); + if ($csvImport->queueUndo()) { + $this->_dispatchImportTask($csvImport, CsvImport_ImportTask::METHOD_UNDO); + $this->_helper->flashMessenger(__('Undo import started. Reload this page for status updates.'), 'success'); + } else { + $this->_helper->flashMessenger(__('Undo import could not be started. Please check error logs for more details.'), 'error'); + } + + $this->_helper->redirector->goto('browse'); + } + + /** + * Clear the import history. + */ + public function clearHistoryAction() + { + $csvImport = $this->_helper->db->findById(); + $importedItemCount = $csvImport->getImportedItemCount(); + + if ($csvImport->isUndone() || + $csvImport->isUndoImportError() || + $csvImport->isOtherError() || + ($csvImport->isImportError() && $importedItemCount == 0)) { + $csvImport->delete(); + $this->_helper->flashMessenger(__('Cleared import from the history.'), 'success'); + } else { + $this->_helper->flashMessenger(__('Cannot clear import history.'), 'error'); + } + $this->_helper->redirector->goto('browse'); + } + + /** + * Get the main Csv Import form. + * + * @return CsvImport_Form_Main + */ + protected function _getMainForm() + { + require_once CSV_IMPORT_DIRECTORY . '/forms/Main.php'; + $csvConfig = $this->_getPluginConfig(); + $form = new CsvImport_Form_Main($csvConfig); + return $form; + } + + /** + * Returns the plugin configuration + * + * @return array + */ + protected function _getPluginConfig() + { + if (!$this->_pluginConfig) { + $config = $this->getInvokeArg('bootstrap')->config->plugins; + if ($config && isset($config->CsvImport)) { + $this->_pluginConfig = $config->CsvImport->toArray(); + } + if (!array_key_exists('fileDestination', $this->_pluginConfig)) { + $this->_pluginConfig['fileDestination'] = + Zend_Registry::get('storage')->getTempDir(); + } + } + return $this->_pluginConfig; + } + + /** + * Returns whether the session is valid. + * + * @return boolean + */ + protected function _sessionIsValid() + { + $requiredKeys = array('itemsArePublic', + 'itemsAreFeatured', + 'collectionId', + 'itemTypeId', + 'ownerId'); + foreach ($requiredKeys as $key) { + if (!isset($this->session->$key)) { + return false; + } + } + return true; + } + + /** + * Dispatch an import task. + * + * @param CsvImport_Import $csvImport The import object + * @param string $method The method name to run in the CsvImport_Import object + */ + protected function _dispatchImportTask($csvImport, $method = null) + { + if ($method === null) { + $method = CsvImport_ImportTask::METHOD_START; + } + $csvConfig = $this->_getPluginConfig(); + + $jobDispatcher = Zend_Registry::get('job_dispatcher'); + $jobDispatcher->setQueueName(CsvImport_ImportTask::QUEUE_NAME); + try { + $jobDispatcher->sendLongRunning('CsvImport_ImportTask', + array( + 'importId' => $csvImport->id, + 'memoryLimit' => @$csvConfig['memoryLimit'], + 'batchSize' => @$csvConfig['batchSize'], + 'method' => $method, + ) + ); + } catch (Exception $e) { + $csvImport->setStatus(CsvImport_Import::OTHER_ERROR); + throw $e; + } + } +} diff --git a/csv_files/test.csv b/csv_files/test.csv new file mode 100644 index 0000000..00539e9 --- /dev/null +++ b/csv_files/test.csv @@ -0,0 +1,4 @@ +title, creator, description, tags, file +"Walden", "Henry David Thoreau", "A man seeks simplicity.", "book, classic, New England", "http://upload.wikimedia.org/wikipedia/commons/2/25/Walden_Thoreau.jpg, http://upload.wikimedia.org/wikipedia/commons/b/ba/Henry_David_Thoreau.jpg" +"The Count of Monte Cristo", "Alexandre Dumas", "A man seeks revenge.", "book, classic, France", "http://upload.wikimedia.org/wikipedia/commons/c/c3/Edmond_Dant%C3%A8s.JPG" +"Narrative of the Life of Frederick Douglass", "Frederick Douglass", "A man seeks freedom.", "book, classic, Maryland", "http://upload.wikimedia.org/wikipedia/commons/f/f5/Sketchofdouglass.jpg" diff --git a/csv_files/test_automap_columns_to_elements.csv b/csv_files/test_automap_columns_to_elements.csv new file mode 100644 index 0000000..b71437a --- /dev/null +++ b/csv_files/test_automap_columns_to_elements.csv @@ -0,0 +1,4 @@ +Dublin Core:Title, Dublin Core:Creator, Dublin Core:Description, tags, Item Type Metadata:URL +"Walden", "Henry David Thoreau", "A man seeks simplicity.", "book, classic, New England", "http://upload.wikimedia.org/wikipedia/commons/2/25/Walden_Thoreau.jpg, http://upload.wikimedia.org/wikipedia/commons/b/ba/Henry_David_Thoreau.jpg" +"The Count of Monte Cristo", "Alexandre Dumas", "A man seeks revenge.", "book, classic, France", "http://upload.wikimedia.org/wikipedia/commons/c/c3/Edmond_Dant%C3%A8s.JPG" +"Narrative of the Life of Frederick Douglass", "Frederick Douglass", "A man seeks freedom.", "book, classic, Maryland", "http://upload.wikimedia.org/wikipedia/commons/f/f5/Sketchofdouglass.jpg" diff --git a/forms/Main.php b/forms/Main.php new file mode 100644 index 0000000..c07ee01 --- /dev/null +++ b/forms/Main.php @@ -0,0 +1,453 @@ +_columnDelimiter = CsvImport_RowIterator::getDefaultColumnDelimiter(); + $this->_fileDelimiter = CsvImport_ColumnMap_File::getDefaultFileDelimiter(); + $this->_tagDelimiter = CsvImport_ColumnMap_Tag::getDefaultTagDelimiter(); + $this->_elementDelimiter = CsvImport_ColumnMap_Element::getDefaultElementDelimiter(); + + $this->setAttrib('id', 'csvimport'); + $this->setMethod('post'); + + $this->_addFileElement(); + $values = get_db()->getTable('ItemType')->findPairsForSelectForm(); + $values = array('' => __('Select Item Type')) + $values; + + $this->addElement('checkbox', 'omeka_csv_export', array( + 'label' => __('Use an export from Omeka CSV Report'), + 'description'=> __('Selecting this will override the options below.')) + ); + + $this->addElement('checkbox', 'automap_columns_names_to_elements', array( + 'label' => __('Automap Column Names to Elements'), + 'description'=> __('Automatically maps columns to elements based on their column names. The column name must be in the form:
{ElementSetName}:{ElementName}'), + 'value' => true) + ); + + $this->addElement('select', 'item_type_id', array( + 'label' => __('Select Item Type'), + 'multiOptions' => $values, + )); + $values = get_db()->getTable('Collection')->findPairsForSelectForm(); + $values = array('' => __('Select Collection')) + $values; + + $this->addElement('select', 'collection_id', array( + 'label' => __('Select Collection'), + 'multiOptions' => $values, + )); + $this->addElement('checkbox', 'items_are_public', array( + 'label' => __('Make All Items Public?'), + )); + $this->addElement('checkbox', 'items_are_featured', array( + 'label' => __('Feature All Items?'), + )); + + $this->_addColumnDelimiterElement(); + $this->_addTagDelimiterElement(); + $this->_addFileDelimiterElement(); + $this->_addElementDelimiterElement(); + + $this->applyOmekaStyles(); + $this->setAutoApplyOmekaStyles(false); + + $submit = $this->createElement('submit', + 'submit', + array('label' => __('Next'), + 'class' => 'submit submit-medium')); + + + $submit->setDecorators(array('ViewHelper', + array('HtmlTag', + array('tag' => 'div', + 'class' => 'csvimportnext')))); + + $this->addElement($submit); + } + + /** + * Return the human readable word for a delimiter + * + * @param string $delimiter The delimiter + * @return string The human readable word for the delimiter + */ + protected function _getHumanDelimiterText($delimiter) + { + $delimiterText = $delimiter; + switch ($delimiter) { + case ',': + $delimiterText = __('comma'); + break; + case ';': + $delimiterText = __('semi-colon'); + break; + case '': + $delimiterText = __('empty'); + break; + } + return $delimiterText; + } + + /** + * Add the column delimiter element to the form + */ + protected function _addColumnDelimiterElement() + { + $delimiter = $this->_columnDelimiter; + $humanDelimiterText = $this->_getHumanDelimiterText($delimiter); + $this->addElement('text', 'column_delimiter', array( + 'label' => __('Choose Column Delimiter'), + 'description' => __('A single character that will be used to ' + . 'separate columns in the file (%s by default).' + . ' Note that spaces, tabs, and other whitespace are not accepted.', $humanDelimiterText), + 'value' => $delimiter, + 'required' => true, + 'size' => '1', + 'validators' => array( + array('validator' => 'NotEmpty', + 'breakChainOnFailure' => true, + 'options' => array('messages' => array( + Zend_Validate_NotEmpty::IS_EMPTY => + __('Column delimiter cannot be whitespace and must be one character long.'), + )), + ), + array('validator' => 'StringLength', 'options' => array( + 'min' => 1, + 'max' => 1, + 'messages' => array( + Zend_Validate_StringLength::TOO_SHORT => + __('Column delimiter cannot be whitespace and must be one character long.'), + Zend_Validate_StringLength::TOO_LONG => + __('Column delimiter cannot be whitespace and must be one character long.'), + ), + )), + ), + )); + } + + /** + * Add the file delimiter element to the form + */ + protected function _addFileDelimiterElement() + { + $delimiter = $this->_fileDelimiter; + $humanDelimiterText = $this->_getHumanDelimiterText($delimiter); + $this->addElement('text', 'file_delimiter', array( + 'label' => __('Choose File Delimiter'), + 'description' => __('A single character that will be used to ' + . 'separate file paths or URLs within a cell (%s by default).' + . ' If the delimiter is empty, then the whole text will be used as the file path or URL. Note that spaces, tabs, and other whitespace are not accepted.', $humanDelimiterText), + 'value' => $delimiter, + 'required' => false, + 'size' => '1', + 'validators' => array( + + array('validator' => 'NotEmpty', + 'breakChainOnFailure' => true, + 'options' => array('type' => 'space', 'messages' => array( + Zend_Validate_NotEmpty::IS_EMPTY => + __('File delimiter cannot be whitespace, and must be empty or one character long.'), + )), + ), + + array('validator' => 'StringLength', 'options' => array( + 'min' => 0, + 'max' => 1, + 'messages' => array( + Zend_Validate_StringLength::TOO_SHORT => + __('File delimiter cannot be whitespace, and must be empty or one character long.'), + Zend_Validate_StringLength::TOO_LONG => + __('File delimiter cannot be whitespace, and must be empty or one character long.'), + ), + )), + ), + )); + } + + /** + * Add the tag delimiter element to the form + */ + protected function _addTagDelimiterElement() + { + $delimiter = $this->_tagDelimiter; + $humanDelimiterText = $this->_getHumanDelimiterText($delimiter); + $this->addElement('text', 'tag_delimiter', array( + 'label' => __('Choose Tag Delimiter'), + 'description' => __('A single character that will be used to ' + . 'separate tags within a cell (%s by default).' + . ' Note that spaces, tabs, and other whitespace are not accepted.', $humanDelimiterText), + 'value' => $delimiter, + 'required' => true, + 'size' => '1', + 'validators' => array( + array('validator' => 'NotEmpty', + 'breakChainOnFailure' => true, + 'options' => array('messages' => array( + Zend_Validate_NotEmpty::IS_EMPTY => + __('Tag delimiter cannot be whitespace and must be one character long.'), + )), + ), + array('validator' => 'StringLength', 'options' => array( + 'min' => 1, + 'max' => 1, + 'messages' => array( + Zend_Validate_StringLength::TOO_SHORT => + __('Tag delimiter cannot be whitespace and must be one character long.'), + Zend_Validate_StringLength::TOO_LONG => + __('Tag delimiter cannot be whitespace and must be one character long.'), + ), + )), + ), + )); + } + + /** + * Add the element delimiter element to the form + */ + protected function _addElementDelimiterElement() + { + $delimiter = $this->_elementDelimiter; + $humanDelimiterText = $this->_getHumanDelimiterText($delimiter); + $this->addElement('text', 'element_delimiter', array( + 'label' => __('Choose Element Delimiter'), + 'description' => __('A single character that will be used to ' + . 'separate metadata elements within a cell (%s by default).' + . ' If the delimiter is empty, then the whole text will be used as the element text. Note that spaces, tabs, and other whitespace are not accepted.', $humanDelimiterText), + 'value' => $delimiter, + 'required' => false, + 'size' => '1', + 'validators' => array( + + array('validator' => 'NotEmpty', + 'breakChainOnFailure' => true, + 'options' => array('type' => 'space', 'messages' => array( + Zend_Validate_NotEmpty::IS_EMPTY => + __('Element delimiter cannot be whitespace, and must be empty or one character long.'), + )), + ), + + array('validator' => 'StringLength', 'options' => array( + 'min' => 0, + 'max' => 1, + 'messages' => array( + Zend_Validate_StringLength::TOO_SHORT => + __('Element delimiter cannot be whitespace, and must be empty or one character long.'), + Zend_Validate_StringLength::TOO_LONG => + __('Element delimiter cannot be whitespace, and must be empty or one character long.'), + ), + )), + ), + )); + } + + /** + * Add the file element to the form + */ + protected function _addFileElement() + { + $size = $this->getMaxFileSize(); + $byteSize = clone $this->getMaxFileSize(); + $byteSize->setType(Zend_Measure_Binary::BYTE); + + $fileValidators = array( + new Zend_Validate_File_Size(array( + 'max' => $byteSize->getValue())), + new Zend_Validate_File_Count(1), + ); + if ($this->_requiredExtensions) { + $fileValidators[] = + new Omeka_Validate_File_Extension($this->_requiredExtensions); + } + if ($this->_requiredMimeTypes) { + $fileValidators[] = + new Omeka_Validate_File_MimeType($this->_requiredMimeTypes); + } + // Random filename in the temporary directory. + // Prevents race condition. + $filter = new Zend_Filter_File_Rename($this->_fileDestinationDir + . '/' . md5(mt_rand() + microtime(true))); + $this->addElement('file', 'csv_file', array( + 'label' => __('Upload CSV File'), + 'required' => true, + 'validators' => $fileValidators, + 'destination' => $this->_fileDestinationDir, + 'description' => __("Maximum file size is %s.", $size->toString()) + )); + $this->csv_file->addFilter($filter); + } + + /** + * Validate the form post + */ + public function isValid($post) + { + // Too much POST data, return with an error. + if (empty($post) && (int)$_SERVER['CONTENT_LENGTH'] > 0) { + $maxSize = $this->getMaxFileSize()->toString(); + $this->csv_file->addError( + __('The file you have uploaded exceeds the maximum post size ' + . 'allowed by the server. Please upload a file smaller ' + . 'than %s.', $maxSize)); + return false; + } + + return parent::isValid($post); + } + + /** + * Set the column delimiter for the form. + * + * @param string $delimiter The column delimiter + */ + public function setColumnDelimiter($delimiter) + { + $this->_columnDelimiter = $delimiter; + } + + /** + * Set the file delimiter for the form. + * + * @param string $delimiter The file delimiter + */ + public function setFileDelimiter($delimiter) + { + $this->_fileDelimiter = $delimiter; + } + + /** + * Set the tag delimiter for the form. + * + * @param string $delimiter The tag delimiter + */ + public function setTagDelimiter($delimiter) + { + $this->_tagDelimiter = $delimiter; + } + + /** + * Set the element delimiter for the form. + * + * @param string $delimiter The element delimiter + */ + public function setElementDelimiter($delimiter) + { + $this->_elementDelimiter = $delimiter; + } + + /** + * Set the file destination for the form. + * + * @param string $dest The file destination + */ + public function setFileDestination($dest) + { + $this->_fileDestinationDir = $dest; + } + + /** + * Set the maximum size for an uploaded CSV file. + * + * If this is not set in the plugin configuration, + * defaults to the smaller of 'upload_max_filesize' and 'post_max_size' + * settings in php. + * + * If this is set but it exceeds the aforementioned php setting, the size + * will be reduced to that lower setting. + * + * @param string|null $size The maximum file size + */ + public function setMaxFileSize($size = null) + { + $postMaxSize = $this->_getBinarySize(ini_get('post_max_size')); + $fileMaxSize = $this->_getBinarySize(ini_get('upload_max_filesize')); + + // Start with the max size as the lower of the two php ini settings. + $strictMaxSize = $postMaxSize->compare($fileMaxSize) > 0 + ? $fileMaxSize + : $postMaxSize; + + // If the plugin max file size setting is lower, choose it as the strict max size + $pluginMaxSizeRaw = trim(get_option(CsvImportPlugin::MEMORY_LIMIT_OPTION_NAME)); + if ($pluginMaxSizeRaw != '') { + if ($pluginMaxSize = $this->_getBinarySize($pluginMaxSizeRaw)) { + $strictMaxSize = $strictMaxSize->compare($pluginMaxSize) > 0 + ? $pluginMaxSize + : $strictMaxSize; + } + } + + if ($size === null) { + $maxSize = $this->_maxFileSize; + } else { + $maxSize = $this->_getBinarySize($size); + } + + if ($maxSize === false || + $maxSize === null || + $maxSize->compare($strictMaxSize) > 0) { + $maxSize = $strictMaxSize; + } + + $this->_maxFileSize = $maxSize; + } + + /** + * Return the max file size + * + * @return string The max file size + */ + public function getMaxFileSize() + { + if (!$this->_maxFileSize) { + $this->setMaxFileSize(); + } + return $this->_maxFileSize; + } + + /** + * Return the binary size measure + * + * @return Zend_Measure_Binary The binary size + */ + protected function _getBinarySize($size) + { + if (!preg_match('/(\d+)([KMG]?)/i', $size, $matches)) { + return false; + } + + $sizeType = Zend_Measure_Binary::BYTE; + + $sizeTypes = array( + 'K' => Zend_Measure_Binary::KILOBYTE, + 'M' => Zend_Measure_Binary::MEGABYTE, + 'G' => Zend_Measure_Binary::GIGABYTE, + ); + + if (count($matches) == 3 && array_key_exists($matches[2], $sizeTypes)) { + $sizeType = $sizeTypes[$matches[2]]; + } + + return new Zend_Measure_Binary($matches[1], $sizeType); + } +} diff --git a/forms/Mapping.php b/forms/Mapping.php new file mode 100644 index 0000000..2783b42 --- /dev/null +++ b/forms/Mapping.php @@ -0,0 +1,321 @@ +setAttrib('id', 'csvimport-mapping'); + $this->setMethod('post'); + + $elementsByElementSetName = $this->_getElementPairs($this->_itemTypeId); + $elementsByElementSetName = array('' => 'Select Below') + + $elementsByElementSetName; + foreach ($this->_columnNames as $index => $colName) { + $rowSubForm = new Zend_Form_SubForm(); + $selectElement = $rowSubForm->createElement('select', + 'element', + array( + 'class' => 'map-element', + 'multiOptions' => $elementsByElementSetName, + 'multiple' => false // see ZF-8452 + ) + ); + $selectElement->setIsArray(true); + if ($this->_automapColumnNamesToElements) { + $selectElement->setValue($this->_getElementIdFromColumnName($colName)); + } + + $rowSubForm->addElement($selectElement); + $rowSubForm->addElement('checkbox', 'html'); + $rowSubForm->addElement('checkbox', 'tags'); + $rowSubForm->addElement('checkbox', 'file'); + $this->_setSubFormDecorators($rowSubForm); + $this->addSubForm($rowSubForm, "row$index"); + } + + $this->addElement('submit', 'submit', + array('label' => __('Import CSV File'), + 'class' => 'submit submit-medium')); + } + + protected function _getElementIdFromColumnName($columnName, $columnNameDelimiter=':') + { + $element = $this->_getElementFromColumnName($columnName, $columnNameDelimiter); + if ($element) { + return $element->id; + } else { + return null; + } + } + + /** + * Return the element from the column name + * + * @param string $columnName The name of the column + * @param string $columnNameDelimiter The column name delimiter + * @return Element|null The element from the column name + */ + protected function _getElementFromColumnName($columnName, $columnNameDelimiter=':') + { + $element = null; + // $columnNameParts is an array like array('Element Set Name', 'Element Name') + if (strlen($columnNameDelimiter) > 0) { + if ($columnNameParts = explode($columnNameDelimiter, $columnName)) { + if (count($columnNameParts) == 2) { + $elementSetName = trim($columnNameParts[0]); + $elementName = trim($columnNameParts[1]); + $element = get_db()->getTable('Element') + ->findByElementSetNameAndElementName($elementSetName, $elementName); + } + } + } + return $element; + } + + + /** + * Load the default decorators. + */ + public function loadDefaultDecorators() + { + $this->setDecorators(array( + array('ViewScript', array( + 'viewScript' => 'index/map-columns-form.php', + 'itemTypeId' => $this->_itemTypeId, + 'form' => $this, + 'columnExamples' => $this->_columnExamples, + 'columnNames' => $this->_columnNames, + )), + )); + } + + /** + * Set the column names + * + * @param array $columnNames The array of column names (which are strings) + */ + public function setColumnNames($columnNames) + { + $this->_columnNames = $columnNames; + } + + /** + * Set the column examples + * + * @param array $columnExamples The array of column examples (which are strings) + */ + public function setColumnExamples($columnExamples) + { + $this->_columnExamples = $columnExamples; + } + + /** + * Set the column examples + * + * @param int $itemTypeId The id of the item type + */ + public function setItemTypeId($itemTypeId) + { + $this->_itemTypeId = $itemTypeId; + } + + /** + * Set the element delimiter + * + * @param int $elementDelimiter The element delimiter + */ + public function setElementDelimiter($elementDelimiter) + { + $this->_elementDelimiter = $elementDelimiter; + } + + /** + * Set the file delimiter + * + * @param int $fileDelimiter The file delimiter + */ + public function setFileDelimiter($fileDelimiter) + { + $this->_fileDelimiter = $fileDelimiter; + } + + /** + * Set the tag delimiter + * + * @param int $tagDelimiter The tag delimiter + */ + public function setTagDelimiter($tagDelimiter) + { + $this->_tagDelimiter = $tagDelimiter; + } + + /** + * Set whether or not to automap column names to elements + * + * @param boolean $flag Whether or not to automap column names to elements + */ + public function setAutomapColumnNamesToElements($flag) + { + $this->_automapColumnNamesToElements = (boolean)$flag; + } + + /** + * Returns array of column maps + * + * @return array The array of column maps + */ + public function getColumnMaps() + { + $columnMaps = array(); + foreach ($this->_columnNames as $key => $colName) { + if ($map = $this->_getColumnMap($key, $colName)) { + if (is_array($map)) { + $columnMaps = array_merge($columnMaps, $map); + } else { + $columnMaps[] = $map; + } + } + } + return $columnMaps; + } + + /** + * Returns whether a subform row contains a tag mapping + * + * @param int $index The subform row index + * @return bool Whether the subform row contains a tag mapping + */ + protected function _isTagMapped($index) + { + return $this->getSubForm("row$index")->tags->isChecked(); + } + + /** + * Returns whether a subform row contains a file mapping + * + * @param int $index The subform row index + * @return bool Whether a subform row contains a file mapping + */ + protected function _isFileMapped($index) + { + return $this->getSubForm("row$index")->file->isChecked(); + } + + /** + * Returns the element id mapped to the subform row + * + * @param int $index The subform row index + * @return mixed The element id mapped to the subform row + */ + protected function _getMappedElementId($index) + { + return $this->_getRowValue($index, 'element'); + } + + /** + * Returns a row element value + * + * @param int $index The subform row index + * @param string $elementName The element name in the row + * @return mixed The row element value + */ + protected function _getRowValue($index, $elementName) + { + return $this->getSubForm("row$index")->$elementName->getValue(); + } + + /** + * Adds decorators to a subform. + * + * @param Zend_Form_SubForm $subForm The subform + */ + protected function _setSubFormDecorators($subForm) + { + // Get rid of the fieldset tag that wraps subforms by default. + $subForm->setDecorators(array( + 'FormElements', + )); + + // Each subform is a row in the table. + foreach ($subForm->getElements() as $el) { + $el->setDecorators(array( + array('decorator' => 'ViewHelper'), + array('decorator' => 'HtmlTag', + 'options' => array('tag' => 'td')), + )); + } + } + + /** + * Get the mappings from one column in the CSV file. + * + * Some columns can have multiple mappings; these are represented + * as an array of maps. + * + * @param int $index The subform row index + * @param string $columnName The name of the CSV file column + * @return CsvImport_ColumnMap|array|null A ColumnMap or an array of ColumnMaps + */ + protected function _getColumnMap($index, $columnName) + { + $columnMap = array(); + + if ($this->_isTagMapped($index)) { + $columnMap[] = new CsvImport_ColumnMap_Tag($columnName, $this->_tagDelimiter); + } + + if ($this->_isFileMapped($index)) { + $columnMap[] = new CsvImport_ColumnMap_File($columnName, $this->_fileDelimiter); + } + + $elementIds = $this->_getMappedElementId($index); + $isHtml = $this->_getRowValue($index, 'html'); + foreach($elementIds as $elementId) { + // Make sure to skip empty mappings + if (!$elementId) { + continue; + } + + $elementMap = new CsvImport_ColumnMap_Element($columnName, $this->_elementDelimiter); + $elementMap->setOptions(array('elementId' => $elementId, + 'isHtml' => $isHtml)); + $columnMap[] = $elementMap; + } + + return $columnMap; + } + + /** + * Returns element selection array for an item type or Dublin Core. + * This is used for selecting elements in form dropdowns + * + * @param int|null $itemTypeId The id of the item type. + * If null, then it only includes Dublin Core elements + * @return array + */ + protected function _getElementPairs($itemTypeId=null) + { + $params = $itemTypeId ? array('item_type_id' => $itemTypeId) + : array('exclude_item_type' => true); + return get_db()->getTable('Element')->findPairsForSelectForm($params); + } +} diff --git a/languages/bg_BG.mo b/languages/bg_BG.mo new file mode 100644 index 0000000000000000000000000000000000000000..0feeb0f994803c93e6d900de506cb8d8cbc554a9 GIT binary patch literal 12978 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 I{DUL^6M^FRbN~PV literal 0 HcmV?d00001 diff --git a/languages/ca_ES.mo b/languages/ca_ES.mo new file mode 100644 index 0000000000000000000000000000000000000000..b634c8142d03accf3234a1771792a0a08893eece GIT binary patch literal 10339 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 literal 0 HcmV?d00001 diff --git a/languages/es.mo b/languages/es.mo new file mode 100644 index 0000000000000000000000000000000000000000..eeccdb128d12bb1984589433af626bf9fc380f0c GIT binary patch literal 10218 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 literal 0 HcmV?d00001 diff --git a/languages/et.mo b/languages/et.mo new file mode 100644 index 0000000000000000000000000000000000000000..e88f4c0f3ba1a4d90525dd8fb0f5ee0b1de872da GIT binary patch literal 5874 zcmb7{UyLM09mnew{IiJiPZR+yP7V&(ncY3k6XrOUV{cCmZ@Kk$?u&P{#c`yaCQ&l#Fvb`~bWYUIP!q_rXVMuE3K#KMC)J--CzYi%{m<$K>+fb+83* zg9qSe;mz=k?InZ_nwDh$FJZK_!69h*C197kHVwyM^O5`2IalKLecS` z@H%)cjq=_{q3HDllyy7>Pf%ZSr}e}YJ=ZenohcRLjS zAB0VK4BiM6D7t>Ne*SLFA40MB=kTNOH&Et%9XKKvH)s(KAdT>cB*3HR}%#PeNHbTd$NNT9@H3F)eS3~zzIfZ~_mL(%gMDC4fs zD7qPV2Q1*n;L}jn{}U+l{tk-2UV$>-tB^1Wm;5e1`$T*UpQ0Hq4VUQhPVW1;WlTy^ z(Be@jYdyk^IbmG!!F}AKk>+0JKEV9|x9Ic{Zi#ckQyt=#+!cSwC3=eOa><%yeWKO) z5`E+nzsPkLx2#F7hq%RW_j2#&UgDNKlS_122;cTTM6>aAkS~wa4~O9=xy3gR*KOig zxsWVel8QZpkaTPDD&TB_Ac1B_7rBPU*5#9k0r4XmY)jCDkz1 zPne`E3Rl)@QJSSBr{!? z_VfliHuWmn;Dxf-(WYly+EmNiMi!uhOcOt!w5F^ItB)s%K2_SGUsU1<5WTM_J?rNa zrnKYV!qnr3@b>tjlT}GKD1n;3GzGd%jOz4lXP~Xb6594WY885zVMW)L#wETv9tPD) z*5tL_&K<6v3^BE?+OEkYXRRnfAmEDJ^jI*X^J`goRO({>WPD<$wmQ|TMb;~<_v%zS zGKuTTYePJkSLI|@Z*4T&6C_Et6`V9}%G)oaBJru5dU)q3JOi)w|uY)?{=$89&P zhQXcMyDwP@tpk|;gxxOH8REQ%>8Mss<%!DLL8E34z;vILabSSDkAVB#gI=H7gCh| znar!Cc~P}7Y=c#0TVi=$Wb2e3ueU7Di{)s`m3^$v43SQbB#DugHI)=L z9W!hCdmVA{*QuNd4K8j3|6d}W3w3L+%ABKUJ=R`1y{P1ycy?Php;)z|rDFV98@8s; zWtHxmk)_hqyMWg_wrnbXC?@gf(!DsG#AZ`D*KIsn^?ak9wRFEMbKjbq>!EJ7-elCdOcb5Vld9*^#*!aJA?KX; zmGv{LXWEUWg1XDl4JoHBeQ18+{>D6iNA&!BYyQZ6^AFC?)3I^Zj+~gV*Y8MUexb2& zm{(ed>Blv5+W4~3E==liUskmASvSh`le-tvUN+JX*@B&*cqF1}?Zdo~_9{waqixNw zrI*>cQ-{_EWi2daYip}n%6M+WZcnW!t}ChEp0nlLJu@q(R+jgKT4>JCERp6MCK{4U zEnQNskb=BF8n#qQ`muBElZ^-V`pHZiwrDKlC)vVU`hj&pbm|nPtf3@1SXGayuXETCPkE|_v6{(xLAz51Q$gXF!3CAH* zdYi;!&IY?rIPb(*p8ComhIkxV)2;WW*kCqIl4ZKwz0}{dY1a%LCkMN3b}y!>^=vs! zb)MZM*if-|n54@qu`L?Xb(b}Z4VSlhmrVC-kEhczf7PqT`(>7f77k#g9&nbar2KTT zK_m#Ddq!yMv^PdmGE7*28|Y2LLB;CanswUk&}e3|dboR`=sBIc!Jtan15WQ=%-N|K zT8Swf$Kt}1z4qmJ?s4JJBU2^T(SM>o?ihpv)u4=h>VwhLGXFONMSAqzz38rN>v?f> z)O7a}2b;ujOcG}%Z0m@ZN~z37PFLh+={V=Doo~ec>Fx#Bb)Gd|lsFnN$3P}=16c?@ zu^g7fTiyD!gz=%WN)*MMk5MwHs(kl?9lfb>gkiCP*W#$|vfxT^;T1xvwtHj@Rwd4p zTN|7?IZC-;xTgS4Pw$CZ0$+l|_NtVCxbn*P-jN-aV}MLA@uN?RXdyHb+b?UWNK1`G zeF-5usH8RRRvrn%K;7W)S}rtvZudE_#2aMDh%{zA=c}&YGS9N4lz_a< zJ#Rb0W&PR~!`|#NBL}-dHDI%>OT#z?@dT$b?}o-Cm={&7s)8dULddD>aY+HmZnaY3 z{ZI&2so^0B>Nw*61xi5pZOU-(I1-J6EB2%!q13DhD8;F&E0f45s~==Vx8ejPnfsO( z23fC~L|IVY%L|1agfbST#SBR{NoT@?nltcPq}6OYEGeTf5sBPNM0UWV=i6KHIty8$`IBbPd6>=|BsGf;JZCbI&Nan=qpf{Oq+$u(osH34U%5vhj z7%5VTb}!{Pi1V2ihlP?laK$FYY+cT#@%8YVB%)-sRM|exWEv|AA4gqmmDxixtM95a zsH_&6dbu=t-qOJeS%|1r+Yl2*9h!8S+DRBaMor0Sa29`+owCf&=Bbz{M& z^BbA#x*d+w|25e|uwFUaPfKkW*S+8luRd)&?jjlFEZ1<0bwj;m>1>LMY+Z!u<-761SM literal 0 HcmV?d00001 diff --git a/languages/fi_FI.mo b/languages/fi_FI.mo new file mode 100644 index 0000000000000000000000000000000000000000..463f7f2fb1b7e4bbb7f61391d84ec6d035db1fce GIT binary patch literal 9599 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 literal 0 HcmV?d00001 diff --git a/languages/it.mo b/languages/it.mo new file mode 100644 index 0000000000000000000000000000000000000000..e9d0c3e58696f1b4a7ccb95ae42012c8a05a90d5 GIT binary patch literal 1945 zcmZvcJ!~9B6vrna1ULwg@NEKmfFj3=)^|?8IA@G8hfOSe_^|KNkN3uVgJ*YEGqe6e zqCpf$DWRc)E(J&+Q4$(DBnlc3LLebJiXf^q6#U=qUTjMl-TJpP`@Y{i|8eHTCj#qP zJZJGda7>5^@IR2(XUBy&34RXV3tk6n;Md?O@Z0M958!>+{{%h={#Kp;4L*eZzu^7g zvAf^{JXLWFWchLMac~-Z5^RGfz)g_#wm^_yx%R zZ-5-%k08taQt@|?^Z5(>4msZh+0T~{;(WgWp9XJ$kAc5}?C&3t^=^SrfXAVPS1|@Y z0uspiya;kWZ-K1e2RXid@CEQ1cnrK0LMLA90T`pQwf(#(b?LUfYX61|YV>W;EQm5E3I-VD8xvSG6rA@dZulE&cPv`ukcw6lTu^I--QE9v*3y%=r zDO3Th2N?3+CI_!==CD;QP(zU5JK*o^?IVi zpxBs%)w*TzdTxqDXHm;q&JJb4OjTV%nZGvT(0-` z?Q0U%SCcxt3h^u{WTK+3l4%p|!6%u~c}@g*?Ck7J1~#TQ)$XBm%A`!CT-TvKQEM-^ z7Z0j5HQA`OY!+08aQ1TK70^@ELF#Ol;(>Htg}GjLDVjNuV^5pPMT=Q%`#MXSG_#?@ z?I4?kO~({YnrO+o)Hf+ZVX^gd7iXz_ojae=b8|E`Fsty387V@*a<>}-biCtC85*eqiXrv!Zx3t3heBU5B%2_5F?L8*scM&lxnm_}bE z^_4N>dN_z>0AhGa)g3;RSAV3WX4PNJEV4`csfJ=3z45{SnHfHX3wM8W@0@$i|D1Do z|J=3xbAj4Y`yar&v95#nfelatZ-BeN zTi`w5&fA5!7u=h2Kez|$!{B!C6v%d`L5}-I%2K*N06vKQL6GCWm%hIOvhGQ62lyMv zer|wl{|9(K_&0b5cnkc6b41X+z^~Y3T{l4!{0n>v?1c#4_d(8U1bhbk2z&)x09(Nw z2uff9Yz9lE<4SXGJfUILTip@Hj zz!TsZ@JaAfknOL7oF2*^k4#DiBnYH09>C<M$)S3Wnpj0rgqZ@W`~Qt`|_n z^aIx$&Wb|3`*N}r1=k8~9ChugB?DPneWJ5Y$rbIEw51c!z9xSknLe@Kem8VVWI80% zm%dC4M29qj(35o1vZynVwtrl7CgEQ)0=fL$?s=}ayok+2moa48p-ugePrmuKY~6(mx7A*U}OWW?lx^bVNSsw^c^L~j6bJ|L^qoTt>B z3q}U=VIkNI0@Eq8mCHv37lD!-);_0V&Q>kgDA7vcrCkk%MUUsc2?bhbNjoP!A4>An zZOZ{8b4GG6v@XAFmb4ecvac20JXM0A>gRL0vKdsu^VvSv&be%!t6E{%bhLthAyJSs zeLs|brl-586+F}$t~IV~o?7(g!CcgV zEZ@>{NXN*WGWm>Ry1oQx1)?)QZxu z^VH=!W!EwdhYoNqNM&$vFdMKxvtJIa$tcOoN{{DkCwDN@)!Eg)TBzo%o+-FafJ>(p zhpR{}z!g9#4AW_)zKY@DwzZutc4$Xe?Ko1u^t5)T&n=;Uc{+OD3|3_3$#yb5R_Ga) z*5P`#pC<>k#nNwUX{F?`?SMm1w^8%K){I#CQO&7&QZ+TBX4O3YYO0P|i@#&)TRh^u zP?OO}bTPV!_cNqsu^5deRD+_irKzQ_PSGVSuVZsW&Esi? zqIcnSTGeo@zBHxgNL`D=G*m4s)HiE9Zaf@~3-t+XSs|>kK=2VnjMZ#d68naV=@ljF z8gjUVvj{DcFM6!>r&xC*8Y9(+KBUdwEGNiH>#>h%F4{axN#c%gp1qn>U#aiZ7vNP+ zbTK}&7>yIx^EfIC7Y%GLl3LtY<$TGnlP++6TRBdf z8-2iy9LLcGcuX5P%K|yff|_AZ@lTZcj019vWkaNnG+04ej#L4f7>$tn8F|&woKdhL z)Kzo@R{&n?t36xYoR7xRE0fk~HokD&EcG=)u*%=RN3TZkiK`KNg)0QR2oD4$YnsV8p4I-g6ZODH*^YB9e literal 0 HcmV?d00001 diff --git a/languages/nl_NL.mo b/languages/nl_NL.mo new file mode 100644 index 0000000000000000000000000000000000000000..b7b7f5ae1938e9ff8aba35e19cd7f1e7e688108f GIT binary patch literal 9961 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 b8-?Vq+8RHX4Owa`Us)-3-Q*vdx_SJ6=50`F literal 0 HcmV?d00001 diff --git a/languages/pt_PT.mo b/languages/pt_PT.mo new file mode 100644 index 0000000000000000000000000000000000000000..c8e02615eecab10ef5ac68e684226f24e864a52b GIT binary patch literal 10141 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 NRCEQ$VD{};a)kktSH literal 0 HcmV?d00001 diff --git a/languages/ro.mo b/languages/ro.mo new file mode 100644 index 0000000000000000000000000000000000000000..2f8680ba48ab12a790400097d966090f13fac042 GIT binary patch literal 9995 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 literal 0 HcmV?d00001 diff --git a/languages/sr_RS.mo b/languages/sr_RS.mo new file mode 100644 index 0000000000000000000000000000000000000000..8590457508719c313de070cd5ab4cee38c9c5fd1 GIT binary patch literal 13306 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 = $columnName; + } + + /** + * Returns the type of column map + * + * @return string The type of column map + */ + public function getType() + { + return $this->_type; + } + + /** + * Use the column mapping to convert a CSV row into a value that can be + * parsed by insert_item() or insert_files_for_item(). + * + * @param array $row The row in the CSV file + * @param array $result + * @return array An array value that can be parsed + * by insert_item() or insert_files_for_item() + */ + abstract public function map($row, $result); +} diff --git a/models/CsvImport/ColumnMap/Collection.php b/models/CsvImport/ColumnMap/Collection.php new file mode 100644 index 0000000..b1572d1 --- /dev/null +++ b/models/CsvImport/ColumnMap/Collection.php @@ -0,0 +1,69 @@ +_type = CsvImport_ColumnMap::TYPE_COLLECTION; + } + + /** + * Map a row to an array that can be parsed by + * insert_item() or insert_files_for_item(). + * + * @param array $row The row to map + * @param array $result + * @return array The result + */ + public function map($row, $result) + { + $result = null; + $collectionTitle = $row[$this->_columnName]; + if ($collectionTitle != '') { + $collection = $this->_getCollectionByTitle($collectionTitle); + if ($collection) { + $result = $collection->id; + } + } + return $result; + } + + /** + * Return a collection by its title + * + * @param string $name The collection name + * @return Collection The collection + */ + protected function _getCollectionByTitle($name) + { + $db = get_db(); + + $elementTable = $db->getTable('Element'); + $element = $elementTable->findByElementSetNameAndElementName('Dublin Core', 'Title'); + + $collectionTable = $db->getTable('Collection'); + $select = $collectionTable->getSelect(); + $select->joinInner(array('s' => $db->ElementText), + 's.record_id = collections.id', array()); + $select->where("s.record_type = 'Collection'"); + $select->where("s.element_id = ?", $element->id); + $select->where("s.text = ?", $name); + + $collection = $collectionTable->fetchObject($select); + if (!$collection) { + _log("Collection not found. Collections must be created with identical names prior to import", Zend_Log::NOTICE); + return false; + } + return $collection; + } +} diff --git a/models/CsvImport/ColumnMap/Element.php b/models/CsvImport/ColumnMap/Element.php new file mode 100644 index 0000000..6a2a468 --- /dev/null +++ b/models/CsvImport/ColumnMap/Element.php @@ -0,0 +1,119 @@ +_type = CsvImport_ColumnMap::TYPE_ELEMENT; + if ($elementDelimiter !== null) { + $this->_elementDelimiter = $elementDelimiter; + } else { + $this->_elementDelimiter = self::getDefaultElementDelimiter(); + } + } + + /** + * Map a row to an array that can be parsed by insert_item() or + * insert_files_for_item(). + * + * @param array $row The row to map + * @param array $result + * @return array The result + */ + public function map($row, $result) + { + if ($this->_isHtml) { + $filter = new Omeka_Filter_HtmlPurifier(); + $text = $filter->filter($row[$this->_columnName]); + } else { + $text = $row[$this->_columnName]; + } + if ($this->_elementDelimiter == '') { + $texts = array($text); + } else { + $texts = explode($this->_elementDelimiter, $text); + } + foreach($texts as $text) { + $result[] = array( + 'element_id' => $this->_elementId, + 'html' => $this->_isHtml ? 1 : 0, + 'text' => $text, + ); + } + return $result; + } + + /** + * Sets the mapping options. + * + * @param array $options + */ + public function setOptions($options) + { + $this->_elementId = $options['elementId']; + $this->_isHtml = (boolean)$options['isHtml']; + } + + /** + * Return the element delimiter. + * + * @return string The element delimiter + */ + public function getElementDelimiter() + { + return $this->_elementDelimiter; + } + + /** + * Return the element id. + * + * @return int The element id + */ + public function getElementId() + { + return $this->_elementId; + } + + /** + * Return whether the element texts are HTML. + * + * @return bool Whether the element texts are HTML + */ + public function isHtml() + { + return $this->_isHtml; + } + + /** + * Returns the default element delimiter. + * Uses the default element delimiter specified in the options table if + * available. + * + * @return string The default element delimiter + */ + static public function getDefaultElementDelimiter() + { + if (!($delimiter = get_option(self::ELEMENT_DELIMITER_OPTION_NAME))) { + $delimiter = self::DEFAULT_ELEMENT_DELIMITER; + } + return $delimiter; + } +} \ No newline at end of file diff --git a/models/CsvImport/ColumnMap/ExportedElement.php b/models/CsvImport/ColumnMap/ExportedElement.php new file mode 100644 index 0000000..eec4fe7 --- /dev/null +++ b/models/CsvImport/ColumnMap/ExportedElement.php @@ -0,0 +1,145 @@ +_type = CsvImport_ColumnMap::TYPE_ELEMENT; + $this->_columnNameDelimiter = self::DEFAULT_COLUMN_NAME_DELIMITER; + $this->_elementDelimiter = self::DEFAULT_ELEMENT_DELIMITER; + $this->_isHtml = true; + + $element = $this->_getElementFromColumnName(); + if ($element) { + $this->_elementId = $element->id; + } else { + $this->_elementId = null; + } + } + + /** + * Map a row to an array that can be parsed by + * insert_item() or insert_files_for_item(). + * + * @param array $row The row to map + * @param array $result + * @return array The result + */ + public function map($row, $result) + { + $filter = new Omeka_Filter_HtmlPurifier(); + $text = $filter->filter($row[$this->_columnName]); + if ($this->_elementDelimiter == '') { + $texts = array($text); + } else { + $texts = explode($this->_elementDelimiter, $text); + } + + if ($this->_elementId) { + foreach($texts as $text) { + $result[] = array('element_id' => $this->_elementId, + 'html' => $this->_isHtml ? 1 : 0, + 'text' => $text); + } + } + + return $result; + } + + /** + * Return the element from the column name + * + * @return Element|null The element from the column name + */ + protected function _getElementFromColumnName() + { + $element = null; + // $columnNameParts is an array like array('Element Set Name', 'Element Name') + if (strlen($this->_columnNameDelimiter) > 0) { + if ($columnNameParts = explode($this->_columnNameDelimiter, $this->_columnName)) { + if (count($columnNameParts) == 2) { + $elementSetName = $columnNameParts[0]; + $elementName = $columnNameParts[1]; + $element = get_db()->getTable('Element') + ->findByElementSetNameAndElementName($elementSetName, $elementName); + } + } + } + return $element; + } + + /** + * Sets the mapping options + * + * @param array $options + */ + public function setOptions($options) + { + $this->_columnNameDelimiter = $options['columnNameDelimiter']; + $this->_elementDelimiter = $options['elementDelimiter']; + $this->_elementId = $options['elementId']; + $this->_isHtml = $options['isHtml']; + } + + /** + * Return the element delimiter + * + * @return string The element delimiter + */ + public function getElementDelimiter() + { + return $this->_elementDelimiter; + } + + /** + * Return the column name delimiter + * + * @return string The column name delimiter + */ + public function getColumnNameDelimiter() + { + return $this->_columnNameDelimiter; + } + + /** + * Return the element id + * + * @return int The element id + */ + public function getElementId() + { + return $this->_elementId; + } + + /** + * Return whether the element texts are HTML + * + * @return bool Whether the element texts are HTML + */ + public function isHtml() + { + return $this->_isHtml; + } +} diff --git a/models/CsvImport/ColumnMap/Featured.php b/models/CsvImport/ColumnMap/Featured.php new file mode 100644 index 0000000..acc0cb6 --- /dev/null +++ b/models/CsvImport/ColumnMap/Featured.php @@ -0,0 +1,39 @@ +_type = CsvImport_ColumnMap::TYPE_FEATURED; + } + + /** + * Map a row to whether the row corresponding to an item is featured or not + * + * @param array $row The row to map + * @param array $result + * @return bool Whether the row corresponding to an item is featured or not + */ + public function map($row, $result) + { + $filter = new Omeka_Filter_Boolean; + $flag = strtolower(trim($row[$this->_columnName])); + if ($flag == 'no') { + return 0; + } else if ($flag == 'yes') { + return 1; + } else { + return $filter->filter($flag); + } + } +} \ No newline at end of file diff --git a/models/CsvImport/ColumnMap/File.php b/models/CsvImport/ColumnMap/File.php new file mode 100644 index 0000000..d33823e --- /dev/null +++ b/models/CsvImport/ColumnMap/File.php @@ -0,0 +1,80 @@ +_type = CsvImport_ColumnMap::TYPE_FILE; + if ($fileDelimiter !== null) { + $this->_fileDelimiter = $fileDelimiter; + } else { + $this->_fileDelimiter = self::getDefaultFileDelimiter(); + } + } + + /** + * Map a row to an array that can be parsed by insert_item() or + * insert_files_for_item(). + * + * @param array $row The row to map + * @param array $result + * @return array The result + */ + public function map($row, $result) + { + $urlString = trim($row[$this->_columnName]); + if ($urlString) { + if ($this->_fileDelimiter == '') { + $rawUrls = array($urlString); + } else { + $rawUrls = explode($this->_fileDelimiter, $urlString); + } + $trimmedUrls = array_map('trim', $rawUrls); + $cleanedUrls = array_diff($trimmedUrls, array('')); + $result = array_merge($result, $cleanedUrls); + $result = array_unique($result); + } + return $result; + } + + /** + * Return the file delimiter. + * + * @return string The file delimiter + */ + public function getFileDelimiter() + { + return $this->_fileDelimiter; + } + + /** + * Returns the default file delimiter. + * Uses the default file delimiter specified in the options table if + * available. + * + * @return string The default file delimiter + */ + static public function getDefaultFileDelimiter() + { + if (!($delimiter = get_option(self::FILE_DELIMITER_OPTION_NAME))) { + $delimiter = self::DEFAULT_FILE_DELIMITER; + } + return $delimiter; + } +} diff --git a/models/CsvImport/ColumnMap/ItemType.php b/models/CsvImport/ColumnMap/ItemType.php new file mode 100644 index 0000000..2fca225 --- /dev/null +++ b/models/CsvImport/ColumnMap/ItemType.php @@ -0,0 +1,33 @@ +_type = CsvImport_ColumnMap::TYPE_ITEM_TYPE; + } + + /** + * Map a row to an array that can be parsed by + * insert_item() or insert_files_for_item(). + * + * @param array $row The row to map + * @param array $result + * @return array The result + */ + public function map($row, $result) + { + $result = $row[$this->_columnName]; + return $result; + } +} \ No newline at end of file diff --git a/models/CsvImport/ColumnMap/Public.php b/models/CsvImport/ColumnMap/Public.php new file mode 100644 index 0000000..9440194 --- /dev/null +++ b/models/CsvImport/ColumnMap/Public.php @@ -0,0 +1,39 @@ +_type = CsvImport_ColumnMap::TYPE_PUBLIC; + } + + /** + * Map a row to whether the row corresponding to an item is public or not + * + * @param array $row The row to map + * @param array $result + * @return bool Whether the row corresponding to an item is public or not + */ + public function map($row, $result) + { + $filter = new Omeka_Filter_Boolean; + $flag = strtolower(trim($row[$this->_columnName])); + if ($flag == 'no') { + return 0; + } else if ($flag == 'yes') { + return 1; + } else { + return $filter->filter($flag); + } + } +} \ No newline at end of file diff --git a/models/CsvImport/ColumnMap/Set.php b/models/CsvImport/ColumnMap/Set.php new file mode 100644 index 0000000..42cb92a --- /dev/null +++ b/models/CsvImport/ColumnMap/Set.php @@ -0,0 +1,56 @@ +_maps = $maps; + } + + /** + * Adds a column map to the set + * + * @param CsvImport_ColumnMap $map The column map + */ + public function add(CsvImport_ColumnMap $map) + { + $this->_maps[] = $map; + } + + /** + * Map a row to an associative array of mappings indexed by column mapping type, + * and where each mapping can be parsed by insert_item() or insert_files_for_item(). + * + * @param array $row The row to map + * @return array The associative array of mappings + */ + public function map(array $row) + { + $allResults = array( + CsvImport_ColumnMap::TYPE_FILE => array(), + CsvImport_ColumnMap::TYPE_ELEMENT => array(), + CsvImport_ColumnMap::TYPE_TAG => array(), + CsvImport_ColumnMap::TYPE_COLLECTION => null, + CsvImport_ColumnMap::TYPE_FEATURED => null, + CsvImport_ColumnMap::TYPE_ITEM_TYPE => null, + CsvImport_ColumnMap::TYPE_PUBLIC => null + + ); + foreach ($this->_maps as $map) { + $subset = $allResults[$map->getType()]; + $allResults[$map->getType()] = $map->map($row, $subset); + } + return $allResults; + } +} \ No newline at end of file diff --git a/models/CsvImport/ColumnMap/Tag.php b/models/CsvImport/ColumnMap/Tag.php new file mode 100644 index 0000000..4155713 --- /dev/null +++ b/models/CsvImport/ColumnMap/Tag.php @@ -0,0 +1,75 @@ +_type = CsvImport_ColumnMap::TYPE_TAG; + if ($tagDelimiter !== null) { + $this->_tagDelimiter = $tagDelimiter; + } else { + $this->_tagDelimiter = self::getDefaultTagDelimiter(); + } + } + + /** + * Map a row to an array of tags. + * + * @param array $row The row to map + * @param array $result + * @return array The array of tags + */ + public function map($row, $result) + { + if ($this->_tagDelimiter == '') { + $rawTags = array($row[$this->_columnName]); + } else { + $rawTags = explode($this->_tagDelimiter, $row[$this->_columnName]); + } + $trimmed = array_map('trim', $rawTags); + $cleaned = array_diff($trimmed, array('')); + $tags = array_merge($result, $cleaned); + return $tags; + } + + /** + * Return the tag delimiter. + * + * @return string The tag delimiter + */ + public function getTagDelimiter() + { + return $this->_tagDelimiter; + } + + /** + * Returns the default tag delimiter. + * Uses the default tag delimiter specified in the options table if + * available. + * + * @return string The default tag delimiter + */ + static public function getDefaultTagDelimiter() + { + if (!($delimiter = get_option(self::TAG_DELIMITER_OPTION_NAME))) { + $delimiter = self::DEFAULT_TAG_DELIMITER; + } + return $delimiter; + } +} diff --git a/models/CsvImport/DuplicateColumnException.php b/models/CsvImport/DuplicateColumnException.php new file mode 100644 index 0000000..a521915 --- /dev/null +++ b/models/CsvImport/DuplicateColumnException.php @@ -0,0 +1,10 @@ +_filePath = $filePath; + if ($columnDelimiter) { + $this->_columnDelimiter = $columnDelimiter; + } + } + + /** + * Absolute path to the file. + * + * @return string + */ + public function getFilePath() + { + return $this->_filePath; + } + + /** + * Get an array of headers for the column names + * + * @return array The array of headers for the column names + */ + public function getColumnNames() + { + if (!$this->_columnNames) { + throw new LogicException("CSV file must be validated before " + . "retrieving the list of columns."); + } + return $this->_columnNames; + } + + /** + * Get an array of example data for the columns. + * + * @return array Examples have the same order as the column names. + */ + public function getColumnExamples() + { + if (!$this->_columnExamples) { + throw new LogicException("CSV file must be validated before " + . "retrieving list of column examples."); + } + return $this->_columnExamples; + } + + /** + * Get an iterator for the rows in the CSV file. + * + * @return CsvImport_RowIterator + */ + public function getIterator() + { + if (!$this->_rowIterator) { + $this->_rowIterator = new CsvImport_RowIterator( + $this->getFilePath(), $this->_columnDelimiter); + } + return $this->_rowIterator; + } + + /** + * Parse metadata. Currently retrieves the column names and an "example" + * row, i.e. the first row after the header. + * + * @return boolean + */ + public function parse() + { + if ($this->_columnNames || $this->_columnExamples) { + throw new RuntimeException('Cannot be parsed twice.'); + } + $rowIterator = $this->getIterator(); + try { + $this->_columnNames = $rowIterator->getColumnNames(); + $this->_columnExamples = $rowIterator->current(); + } catch (CsvImport_DuplicateColumnException $e) { + $this->_parseErrors[] = $e->getMessage() + . ' ' . __('Please ensure that all column names are unique.'); + return false; + } catch (CsvImport_MissingColumnException $e) { + $this->_parseErrors[] = $e->getMessage() + . ' ' . __('Please ensure that the CSV file is formatted correctly' + . ' and contains the expected number of columns for each row.'); + return false; + } + return true; + } + + /** + * Get the error string + * + * @return string + */ + public function getErrorString() + { + return join(' ', $this->_parseErrors); + } +} \ No newline at end of file diff --git a/models/CsvImport/Import.php b/models/CsvImport/Import.php new file mode 100644 index 0000000..2b6797f --- /dev/null +++ b/models/CsvImport/Import.php @@ -0,0 +1,774 @@ +_mixins[] = new Mixin_Timestamp($this, 'added', null); + } + + /** + * Sets whether the imported items are public + * + * @param mixed $flag A boolean representation + */ + public function setItemsArePublic($flag) + { + $booleanFilter = new Omeka_Filter_Boolean; + $this->is_public = $booleanFilter->filter($flag); + } + + /** + * Sets whether the imported items are featured + * + * @param mixed $flag A boolean representation + */ + public function setItemsAreFeatured($flag) + { + $booleanFilter = new Omeka_Filter_Boolean; + $this->is_featured = $booleanFilter->filter($flag); + } + + /** + * Sets the collection id of the collection to which the imported items belong + * + * @param int $id The collection id + */ + public function setCollectionId($id) + { + if(!$id) { + $this->collection_id = null; + } else { + $this->collection_id = (int)$id; + } + } + + /** + * Sets the column delimiter in the imported CSV file + * + * @param string The column delimiter of the imported CSV file + */ + public function setColumnDelimiter($delimiter) + { + $this->delimiter = $delimiter; + } + + /** + * Sets the file path of the imported CSV file + * + * @param string The file path of the imported CSV file + */ + public function setFilePath($path) + { + $this->file_path = $path; + } + + /** + * Sets the original filename of the imported CSV file + * + * @param string The original filename of the imported CSV file + */ + public function setOriginalFilename($filename) + { + $this->original_filename = $filename; + } + + /** + * Sets the item type id of the item type of every imported item + * + * @param int $id The item type id + */ + public function setItemTypeId($id) + { + if(!$id) { + $this->item_type_id = null; + } else { + $this->item_type_id = (int)$id; + } + + } + + /** + * Sets the status of the import + * + * @param string The status of the import + */ + public function setStatus($status) + { + $this->status = (string)$status; + } + + /** + * Sets the user id of the owner of the imported items + * + * @param int $id The user id of the owner of the imported items + */ + public function setOwnerId($id) + { + $this->owner_id = (int)$id; + } + + /** + * Sets whether the import is an Omeka export + * + * @param mixed $flag A boolean representation + */ + public function setIsOmekaExport($flag) + { + $this->_isOmekaExport = $flag; + } + + /** + * Sets the column maps for the import + * + * @param CsvImport_ColumnMap_Set|array $maps The set of column maps + * @throws InvalidArgumentException + */ + public function setColumnMaps($maps) + { + if ($maps instanceof CsvImport_ColumnMap_Set) { + $mapSet = $maps; + } else if (is_array($maps)) { + $mapSet = new CsvImport_ColumnMap_Set($maps); + } else { + throw new InvalidArgumentException("Maps must be either an " + . "array or an instance of CsvImport_ColumnMap_Set."); + } + $this->_columnMaps = $mapSet; + } + + /** + * Set the number of items to create before pausing the import. + * + * Used primarily for performance reasons, i.e. long-running imports may + * time out or hog system resources in such a way that prevents other + * imports from running. When used in conjunction with Omeka_Job and + * resume(), this can be used to spawn multiple sequential jobs for a given + * import. + * + * @param int $size + */ + public function setBatchSize($size) + { + $this->_batchSize = (int)$size; + } + + /** + * Executes before the record is deleted. + * @param array $args + */ + protected function beforeSave($args) + { + $this->serialized_column_maps = serialize($this->getColumnMaps()); + } + + /** + * Executes after the record is deleted. + */ + protected function afterDelete() + { + if (file_exists($this->file_path)) { + unlink($this->file_path); + } + } + + /** + * Returns whether there is an error + * + * @return boolean Whether there is an error + */ + public function isError() + { + return $this->isImportError() || + $this->isUndoImportError() || + $this->isOtherError(); + } + + /** + * Returns whether there is an error with the import process + * + * @return boolean Whether there is an error with the import process + */ + public function isImportError() + { + return $this->status == self::IMPORT_ERROR; + } + + /** + * Returns whether there is an error with the undo import process + * + * @return boolean Whether there is an error with the undo import process + */ + public function isUndoImportError() + { + return $this->status == self::UNDO_IMPORT_ERROR; + } + + /** + * Returns whether there is an error that is neither related to an import nor undo import process + * + * @return boolean Whether there is an error that is neither related to an import nor undo import process + */ + public function isOtherError() + { + return $this->status == self::OTHER_ERROR; + } + + /** + * Returns whether the import is stopped + * + * @return boolean Whether the import is stopped + */ + public function isStopped() + { + return $this->status == self::STOPPED; + } + + /** + * Returns whether the import is queued + * + * @return boolean Whether the import is queued + */ + public function isQueued() + { + return $this->status == self::QUEUED; + } + + /** + * Returns whether the undo import is queued + * + * @return boolean Whether the undo import is queued + */ + public function isQueuedUndo() + { + return $this->status == self::QUEUED_UNDO; + } + + /** + * Returns whether the import is completed + * + * @return boolean Whether the import is completed + */ + public function isCompleted() + { + return $this->status == self::COMPLETED; + } + + /** + * Returns whether the import is undone + * + * @return boolean Whether the import is undone + */ + public function isUndone() + { + return $this->status == self::COMPLETED_UNDO; + } + + /** + * Imports the CSV file. This function can only be run once. + * To import the same csv file, you will have to + * create another instance of CsvImport_Import and run start + * Sets import status to self::IN_PROGRESS + * + * @return boolean Whether the import was successful + */ + public function start() + { + $this->status = self::IN_PROGRESS; + $this->save(); + $this->_log("Started import."); + $this->_importLoop($this->file_position); + return !$this->isError(); + } + + /** + * Completes the import. + * Sets import status to self::COMPLETED + * + * @return boolean Whether the import was successfully completed + */ + public function complete() + { + if ($this->isCompleted()) { + $this->_log("Cannot complete an import that is already completed."); + return false; + } + $this->status = self::COMPLETED; + $this->save(); + $this->_log("Completed importing $this->_importedCount items (skipped " + . "$this->skipped_row_count rows)."); + return true; + } + + /** + * Completes the undo import. + * Sets import status to self::COMPLETED_UNDO + * + * @return boolean Whether the undo import was successfully completed + */ + public function completeUndo() + { + if ($this->isUndone()) { + $this->_log("Cannot complete an undo import that is already undone."); + return false; + } + $this->status = self::COMPLETED_UNDO; + $this->save(); + $this->_log("Completed undoing the import."); + return true; + } + + /** + * Resumes the import. + * Sets import status to self::IN_PROGRESS + * + * @return boolean Whether the import was successful after it was resumed + */ + public function resume() + { + if (!$this->isQueued() && !$this->isQueuedUndo()) { + $this->_log("Cannot resume an import or undo import that has not been queued."); + return false; + } + + $undoImport = $this->isQueuedUndo(); + + if ($this->isQueued()) { + $this->status = self::IN_PROGRESS; + $this->save(); + $this->_log("Resumed import."); + $this->_importLoop($this->file_position); + } else { + $this->status = self::IN_PROGRESS_UNDO; + $this->save(); + $this->_log("Resumed undo import."); + $this->_undoImportLoop(); + } + + return !$this->isError(); + } + + /** + * Stops the import or undo import. + * Sets import status to self::STOPPED + * + * @return boolean Whether the import or undo import was stopped due to an error + */ + public function stop() + { + // If the import or undo import loops were prematurely stopped while in progress, + // then there is an error, otherwise there is no error, i.e. the import + // or undo import was completed + if ($this->status != self::IN_PROGRESS and + $this->status != self::IN_PROGRESS_UNDO) { + return false; // no error + } + + // The import or undo import loop was prematurely stopped + $logMsg = "Stopped import or undo import due to error"; + if ($error = error_get_last()) { + $logMsg .= ": " . $error['message']; + } else { + $logMsg .= '.'; + } + $this->status = self::STOPPED; + $this->save(); + $this->_log($logMsg, Zend_Log::ERR); + return true; // stopped with an error + } + + /** + * Queue the import. + * Sets import status to self::QUEUED + * + * @return boolean Whether the import was successfully queued + */ + public function queue() + { + if ($this->isError()) { + $this->_log("Cannot queue an import that has an error."); + return false; + } + + if ($this->isStopped()) { + $this->_log("Cannot queue an import that has been stopped."); + return false; + } + + if ($this->isCompleted()) { + $this->_log("Cannot queue an import that has been completed."); + return false; + } + + if ($this->isUndone()) { + $this->_log("Cannot queue an import that has been undone."); + return false; + } + + $this->status = self::QUEUED; + $this->save(); + $this->_log("Queued import."); + return true; + } + + /** + * Queue the undo import. + * Sets import status to self::QUEUED_UNDO + * + * @return boolean Whether the undo import was successfully queued + */ + public function queueUndo() + { + if ($this->isUndoImportError()) { + $this->_log("Cannot queue an undo import that has an undo import error."); + return false; + } + + if ($this->isOtherError()) { + $this->_log("Cannot queue an undo import that has an error."); + return false; + } + + if ($this->isStopped()) { + $this->_log("Cannot queue an undo import that has been stopped."); + return false; + } + + if ($this->isUndone()) { + $this->_log("Cannot queue an undo import that has been undone."); + return false; + } + + $this->status = self::QUEUED_UNDO; + $this->save(); + $this->_log("Queued undo import."); + return true; + } + + /** + * Undo the import. + * Sets import status to self::IN_PROGRESS_UNDO and then self::COMPLETED_UNDO + * + * @return boolean Whether the import was successfully undone + */ + public function undo() + { + $this->status = self::IN_PROGRESS_UNDO; + $this->save(); + $this->_log("Started undo import."); + $this->_undoImportLoop(); + return !$this->isError(); + } + + /** + * Returns the CsvImport_File object for the import + * + * @return CsvImport_File + */ + public function getCsvFile() + { + if (empty($this->_csvFile)) { + $this->_csvFile = new CsvImport_File($this->file_path, $this->delimiter); + } + return $this->_csvFile; + } + + /** + * Returns the set of column maps for the import + * + * @throws UnexpectedValueException + * @return CsvImport_ColumnMap_Set The set of column maps for the import + */ + public function getColumnMaps() + { + if ($this->_columnMaps === null) { + $columnMaps = unserialize($this->serialized_column_maps); + if (!($columnMaps instanceof CsvImport_ColumnMap_Set)) { + throw new UnexpectedValueException("Column maps must be " + . "an instance of CsvImport_ColumnMap_Set. Instead, the " + . "following was given: " . var_export($columnMaps, true)); + } + $this->_columnMaps = $columnMaps; + } + return $this->_columnMaps; + } + + /** + * Returns the number of items currently imported. If a user undoes an import, + * this number decreases to the number of items left to remove. + * + * @return int The number of items imported minus the number of items undone + */ + public function getImportedItemCount() + { + $iit = $this->getTable('CsvImport_ImportedItem'); + $sql = $iit->getSelectForCount()->where('`import_id` = ?'); + $importedItemCount = $this->getDb()->fetchOne($sql, array($this->id)); + return $importedItemCount; + } + + /** + * Runs the import loop + * + * @param int $startAt A row number in the CSV file. + * @throws Exception + * @return boolean Whether the import loop was successfully run + */ + protected function _importLoop($startAt = null) + { + try { + register_shutdown_function(array($this, 'stop')); + $rows = $this->getCsvFile()->getIterator(); + $rows->rewind(); + if ($startAt) { + $rows->seek($startAt); + } + $rows->skipInvalidRows(true); + $this->_log("Running item import loop. Memory usage: %memory%"); + while ($rows->valid()) { + $row = $rows->current(); + $index = $rows->key(); + $this->skipped_row_count += $rows->getSkippedCount(); + if ($item = $this->_addItemFromRow($row)) { + release_object($item); + } else { + $this->skipped_item_count++; + $this->_log("Skipped item on row #{$index}.", Zend_Log::WARN); + } + $this->file_position = $this->getCsvFile()->getIterator()->tell(); + if ($this->_batchSize && ($index % $this->_batchSize == 0)) { + $this->_log("Completed importing batch of $this->_batchSize " + . "items. Memory usage: %memory%"); + return $this->queue(); + } + $rows->next(); + } + $this->skipped_row_count += $rows->getSkippedCount(); + return $this->complete(); + } catch (Omeka_Job_Worker_InterruptException $e) { + // Interruptions usually indicate that we should resume from + // the last stopping position. + return $this->queue(); + } catch (Exception $e) { + $this->status = self::IMPORT_ERROR; + $this->save(); + $this->_log($e, Zend_Log::ERR); + throw $e; + } + } + + /** + * Runs the undo import loop + * + * @throws Exception + * @return boolean Whether the undo import loop was successfully run + */ + protected function _undoImportLoop() + { + try { + $itemLimitPerQuery = self::UNDO_IMPORT_ITEM_LIMIT_PER_QUERY; + $batchSize = intval($this->_batchSize); + if ($batchSize > 0) { + $itemLimitPerQuery = min($itemLimitPerQuery, $batchSize); + } + register_shutdown_function(array($this, 'stop')); + $db = $this->getDb(); + $searchSql = "SELECT `item_id` FROM $db->CsvImport_ImportedItem" + . " WHERE `import_id` = " . (int)$this->id + . " LIMIT " . $itemLimitPerQuery; + $it = $this->getTable('Item'); + $deletedItemCount = 0; + while ($itemIds = $db->fetchCol($searchSql)) { + $inClause = 'IN (' . join(', ', $itemIds) . ')'; + $items = $it->fetchObjects( + $it->getSelect()->where("`items`.`id` $inClause")); + $deletedItemIds = array(); + foreach ($items as $item) { + $itemId = $item->id; + $item->delete(); + release_object($item); + $deletedItemIds[] = $itemId; + $deletedItemCount++; + if ($batchSize > 0 && $deletedItemCount == $batchSize) { + $inClause = 'IN (' . join(', ', $deletedItemIds) . ')'; + $db->delete($db->CsvImport_ImportedItem, "`item_id` $inClause"); + $this->_log("Completed undoing the import of a batch of $batchSize " + . "items. Memory usage: %memory%"); + return $this->queueUndo(); + } + } + $db->delete($db->CsvImport_ImportedItem, "`item_id` $inClause"); + } + return $this->completeUndo(); + } catch (Omeka_Job_Worker_InterruptException $e) { + if ($db && $deletedItemIds) { + $inClause = 'IN (' . join(', ', $deletedItemIds) . ')'; + $db->delete($db->CsvImport_ImportedItem, "`item_id` $inClause"); + } + return $this->queueUndo(); + } catch (Exception $e) { + $this->status = self::UNDO_IMPORT_ERROR; + $this->save(); + $this->_log($e, Zend_Log::ERR); + throw $e; + } + } + + /** + * Adds a new item based on a row string in the CSV file and returns it. + * + * @param string $row A row string in the CSV file + * @return Item|boolean The inserted item or false if an item could not be added. + */ + protected function _addItemFromRow($row) + { + $result = $this->getColumnMaps()->map($row); + $tags = $result[CsvImport_ColumnMap::TYPE_TAG]; + $itemMetadata = array( + Builder_Item::IS_PUBLIC => $this->is_public, + Builder_Item::IS_FEATURED => $this->is_featured, + Builder_Item::ITEM_TYPE_ID => $this->item_type_id, + Builder_Item::COLLECTION_ID => $this->collection_id, + Builder_Item::TAGS => $tags, + ); + + // If this is coming from CSV Report, bring in the itemmetadata coming from the report + if (!is_null($result[CsvImport_ColumnMap::TYPE_COLLECTION])) { + $itemMetadata[Builder_Item::COLLECTION_ID] = $result[CsvImport_ColumnMap::TYPE_COLLECTION]; + } + if (!is_null($result[CsvImport_ColumnMap::TYPE_PUBLIC])) { + $itemMetadata[Builder_Item::IS_PUBLIC] = $result[CsvImport_ColumnMap::TYPE_PUBLIC]; + } + if (!is_null($result[CsvImport_ColumnMap::TYPE_FEATURED])) { + $itemMetadata[Builder_Item::IS_FEATURED] = $result[CsvImport_ColumnMap::TYPE_FEATURED]; + } + if (!empty($result[CsvImport_ColumnMap::TYPE_ITEM_TYPE])) { + $itemMetadata[Builder_Item::ITEM_TYPE_NAME] = $result[CsvImport_ColumnMap::TYPE_ITEM_TYPE]; + } + + $elementTexts = $result[CsvImport_ColumnMap::TYPE_ELEMENT]; + try { + $item = insert_item($itemMetadata, $elementTexts); + } catch (Omeka_Validator_Exception $e) { + $this->_log($e, Zend_Log::ERR); + return false; + } catch (Omeka_Record_Builder_Exception $e) { + $this->_log($e, Zend_Log::ERR); + return false; + } + + $fileUrls = $result[CsvImport_ColumnMap::TYPE_FILE]; + foreach ($fileUrls as $url) { + try { + $file = insert_files_for_item($item, 'Url', $url, + array('ignore_invalid_files' => false)); + } catch (Omeka_File_Ingest_InvalidException $e) { + $msg = "Invalid file URL '$url': " + . $e->getMessage(); + $this->_log($msg, Zend_Log::ERR); + $item->delete(); + release_object($item); + return false; + } catch (Omeka_File_Ingest_Exception $e) { + $msg = "Could not import file '$url': " + . $e->getMessage(); + $this->_log($msg, Zend_Log::ERR); + $item->delete(); + release_object($item); + return false; + } + release_object($file); + } + + // Makes it easy to unimport the item later. + $this->_recordImportedItemId($item->id); + return $item; + } + + /** + * Records that an item was successfully imported in the database + * + * @param int $itemId The id of the item imported + */ + protected function _recordImportedItemId($itemId) + { + $csvImportedItem = new CsvImport_ImportedItem(); + $csvImportedItem->setArray(array( + 'import_id' => $this->id, + 'item_id' => $itemId) + ); + $csvImportedItem->save(); + $this->_importedCount++; + } + + /** + * Log an import message + * Every message will log the import ID. + * Messages that have %memory% will include memory usage information. + * + * @param string $msg The message to log + * @param int $priority The priority of the message + */ + protected function _log($msg, $priority = Zend_Log::DEBUG) + { + $prefix = "[CsvImport][#{$this->id}]"; + $msg = str_replace('%memory%', memory_get_usage(), $msg); + _log("$prefix $msg", $priority); + } +} diff --git a/models/CsvImport/ImportTask.php b/models/CsvImport/ImportTask.php new file mode 100644 index 0000000..38bfc5a --- /dev/null +++ b/models/CsvImport/ImportTask.php @@ -0,0 +1,103 @@ +_method = self::METHOD_START; + parent::__construct($options); + } + + /** + * Performs the import task + */ + public function perform() + { + if ($this->_memoryLimit) { + ini_set('memory_limit', $this->_memoryLimit); + } + if (!($import = $this->_getImport())) { + return; + } + + $import->setBatchSize($this->_batchSize); + call_user_func(array($import, $this->_method)); + + if ($import->isQueued() || $import->isQueuedUndo()) { + $this->_dispatcher->setQueueName(self::QUEUE_NAME); + $this->_dispatcher->sendLongRunning(__CLASS__, + array( + 'importId' => $import->id, + 'memoryLimit' => $this->_memoryLimit, + 'method' => 'resume', + 'batchSize' => $this->_batchSize, + ) + ); + } + } + + /** + * Set the number of items to create before pausing the import. + * + * @param int $size + */ + public function setBatchSize($size) + { + $this->_batchSize = (int)$size; + } + + /** + * Set the memory limit for the task + * + * @param string $limit + */ + public function setMemoryLimit($limit) + { + $this->_memoryLimit = $limit; + } + + /** + * Set the import id for the task + * + * @param int $id + */ + public function setImportId($id) + { + $this->_importId = (int)$id; + } + + /** + * Set the method name of the import object to be run by the task + * + * @param string $name + */ + public function setMethod($name) + { + $this->_method = $name; + } + + /** + * Returns the import of the import task + * + * @return CsvImport_Import The import of the import task + */ + protected function _getImport() + { + return $this->_db->getTable('CsvImport_Import')->find($this->_importId); + } +} diff --git a/models/CsvImport/ImportedItem.php b/models/CsvImport/ImportedItem.php new file mode 100644 index 0000000..d5233bd --- /dev/null +++ b/models/CsvImport/ImportedItem.php @@ -0,0 +1,34 @@ +item_id; + } + + /** + * Returns the import id for the imported item + * + * @return int The import id. + */ + public function getImportId() + { + return $this->import_id; + } +} diff --git a/models/CsvImport/MissingColumnException.php b/models/CsvImport/MissingColumnException.php new file mode 100644 index 0000000..c0844be --- /dev/null +++ b/models/CsvImport/MissingColumnException.php @@ -0,0 +1,10 @@ +_filePath = $filePath; + if ($columnDelimiter !== null) { + $this->_columnDelimiter = $columnDelimiter; + } else { + $this->_columnDelimiter = self::getDefaultColumnDelimiter(); + } + } + + /** + * Returns the column delimiter. + * + * @return string The column delimiter + */ + public function getColumnDelimiter() + { + return $this->_columnDelimiter; + } + + /** + * Rewind the Iterator to the first element. + * Similar to the reset() function for arrays in PHP. + * + * @throws CsvImport_DuplicateColumnException + */ + public function rewind() + { + if ($this->_handle) { + fclose($this->_handle); + $this->_handle = null; + } + $this->_currentRowNumber = 0; + $this->_valid = true; + // First row should always be the header. + $colRow = $this->_getNextRow(); + $this->_colNames = array_map("trim", array_keys(array_flip($colRow))); + $this->_colCount = count($colRow); + $uniqueColCount = count($this->_colNames); + if ($uniqueColCount != $this->_colCount) { + throw new CsvImport_DuplicateColumnException("Header row " + . "contains $uniqueColCount unique column name(s) for " + . $this->_colCount . " columns."); + } + $this->_moveNext(); + } + + /** + * Return the current element. + * Similar to the current() function for arrays in PHP. + * + * @return mixed current element + */ + public function current() + { + return $this->_currentRow; + } + + /** + * Return the identifying key of the current element. + * Similar to the key() function for arrays in PHP. + * + * @return scalar + */ + public function key() + { + return $this->_currentRowNumber; + } + + /** + * Move forward to next element. + * Similar to the next() function for arrays in PHP. + * + * @throws Exception + */ + public function next() + { + try { + $this->_moveNext(); + } catch (CsvImport_MissingColumnException $e) { + if ($this->_skipInvalidRows) { + $this->_skippedRowCount++; + $this->next(); + } else { + throw $e; + } + } + } + + /** + * Seek to a starting position for the file. + * + * @param int The offset + */ + public function seek($index) + { + if (!$this->_colNames) { + $this->rewind(); + } + $fh = $this->_getFileHandle(); + fseek($fh, $index); + $this->_moveNext(); + } + + /** + * Returns current position of the file pointer. + * + * @return int The current position of the filer pointer + */ + public function tell() + { + return ftell($this->_getFileHandle()); + } + + /** + * Move to the next row in the file. + */ + protected function _moveNext() + { + if ($nextRow = $this->_getNextRow()) { + $this->_currentRow = $this->_formatRow($nextRow); + } else { + $this->_currentRow = array(); + } + + if (!$this->_currentRow) { + fclose($this->_handle); + $this->_valid = false; + $this->_handle = null; + } + } + + /** + * Returns whether the current file position is valid. + * + * @return boolean + */ + public function valid() + { + if (!file_exists($this->_filePath)) { + return false; + } + if (!$this->_getFileHandle()) { + return false; + } + return $this->_valid; + } + + /** + * Returns array of column names. + * + * @return array + */ + public function getColumnNames() + { + if (!$this->_colNames) { + $this->rewind(); + } + return $this->_colNames; + } + + /** + * Returns the number of rows that were skipped since the last time the + * function was called. + * + * Skipped count is reset to 0 after each call to getSkippedCount(). This + * makes it easier to aggregate the number over multiple job runs. + * + * @return int The number of rows skipped since last time function was called + */ + public function getSkippedCount() + { + $skipped = $this->_skippedRowCount; + $this->_skippedRowCount = 0; + return $skipped; + } + + /** + * Sets whether to skip invalid rows. + * + * @param boolean $flag + */ + public function skipInvalidRows($flag) + { + $this->_skipInvalidRows = (boolean)$flag; + } + + /** + * Formats a row. + * + * @throws LogicException + * @throws CsvImport_MissingColumnException + * @return array The formatted row + */ + protected function _formatRow($row) + { + $formattedRow = array(); + if (!isset($this->_colNames)) { + throw new LogicException("Row cannot be formatted until the column " + . "names have been set."); + } + if (count($row) != $this->_colCount) { + $printable = substr(join($this->_columnDelimiter, $row), 0, 30) . '...'; + throw new CsvImport_MissingColumnException("Row beginning with " + . "'$printable' does not have the required {$this->_colCount} " + . "rows."); + } + for ($i = 0; $i < $this->_colCount; $i++) + { + $formattedRow[$this->_colNames[$i]] = $row[$i]; + } + return $formattedRow; + } + + /** + * Returns a file handle for the CSV file. + * + * @return resource The file handle + */ + protected function _getFileHandle() + { + if (!$this->_handle) { + ini_set('auto_detect_line_endings', true); + $this->_handle = fopen($this->_filePath, 'r'); + } + return $this->_handle; + } + + /** + * Returns the next row in the CSV file. + * + * @return array The row + */ + protected function _getNextRow() + { + $currentRow = array(); + $handle = $this->_getFileHandle(); + while (($row = fgetcsv($handle, 0, $this->_columnDelimiter)) !== FALSE) { + $this->_currentRowNumber++; + return $row; + } + } + + /** + * Returns the default column delimiter. + * Uses the default column delimiter specified in the options table if + * available. + * + * @return string The default column delimiter + */ + static public function getDefaultColumnDelimiter() + { + if (!($delimiter = get_option(self::COLUMN_DELIMITER_OPTION_NAME))) { + $delimiter = self::DEFAULT_COLUMN_DELIMITER; + } + return $delimiter; + } +} diff --git a/models/Table/CsvImport/Import.php b/models/Table/CsvImport/Import.php new file mode 100644 index 0000000..407ddf0 --- /dev/null +++ b/models/Table/CsvImport/Import.php @@ -0,0 +1,11 @@ + + 'Import Items', + 'action' => 'index', + 'module' => 'csv-import', + ), + array( + 'label' => 'Status', + 'action' => 'browse', + 'module' => 'csv-import', + ), + ); + echo nav($navArray, 'admin_navigation_settings'); +?> + \ No newline at end of file diff --git a/views/admin/css/csv-import-main.css b/views/admin/css/csv-import-main.css new file mode 100644 index 0000000..50c3824 --- /dev/null +++ b/views/admin/css/csv-import-main.css @@ -0,0 +1,58 @@ +#csvimport { + overflow: visible; +} + +#csv-import-column-mappings-table td { + vertical-align:top; +} + +.csv-import-column-map { + border-top:2px solid #D7D5C4; + padding-top:10px; +} + +#csvimport span.add-element { + background-image: url('../../../../../application/views/scripts/images/silk-icons/add.png'); + background-repeat: no-repeat; + width: 20px; +} + +table#column-mappings div.inputs { + width: auto; +} + + +/* id=section-nav changed to class=section-nav to enable it to work with + * Zend_Navigation. + */ +.horizontal-nav ul.section-nav { + font-size: 1.1em; + margin: 0; + padding: 9px 0 12px; +} + +.horizontal-nav ul.section-nav li { + display: inline; +} + +.horizontal-nav ul.section-nav a { + color: #446677; + padding: 9px 12px 12px; + text-decoration: none; + width: 126px; +} +/* Duplicate of "li.current" definition. Zend_Navigation uses "active" for class + * instead of current. + */ +.horizontal-nav ul.section-nav li.active a { + background: none repeat scroll 0 0 #D7D5C4; + border-color: #D7D5C4; + border-style: solid; + border-width: 0 0 1px; + color: #333333; + font-weight: bold; +} + +div.csvimportnext { + clear:both; +} \ No newline at end of file diff --git a/views/admin/index/browse.php b/views/admin/index/browse.php new file mode 100644 index 0000000..b0698d3 --- /dev/null +++ b/views/admin/index/browse.php @@ -0,0 +1,84 @@ + __('CSV Import'))); +?> + +
+

+ + + + + + + 'th scope="col"', 'list_tag' => '')); + ?> + + + + + + + + + + + getImportedItemCount(); ?> + + + + + + isCompleted() + || $csvImport->isStopped() + || ($csvImport->isImportError() && $importedItemCount > 0)): ?> + url(array('action' => 'undo-import', + 'id' => $csvImport->id), + 'default'); + ?> + + isUndone() || + $csvImport->isUndoImportError() || + $csvImport->isOtherError() || + ($csvImport->isImportError() && $importedItemCount == 0)): ?> + url(array('action' => 'clear-history', + 'id' => $csvImport->id), + 'default'); + ?> + + + + + + + +
added, Zend_Date::DATETIME_SHORT)); ?>original_filename); ?>skipped_item_count); ?>skipped_row_count); ?>status, 'all'))); ?> + + + +
+ +

+ + +
+ + diff --git a/views/admin/index/check-omeka-csv.php b/views/admin/index/check-omeka-csv.php new file mode 100644 index 0000000..852667f --- /dev/null +++ b/views/admin/index/check-omeka-csv.php @@ -0,0 +1,19 @@ + __('CSV Import Errors'))); +?> + +
+

+ +

+

+ +

+

+ +
+ diff --git a/views/admin/index/index.php b/views/admin/index/index.php new file mode 100644 index 0000000..f183fb3 --- /dev/null +++ b/views/admin/index/index.php @@ -0,0 +1,20 @@ + __('CSV Import'))); +?> + +
+ +

+ form; ?> +
+ + diff --git a/views/admin/index/map-columns-form.php b/views/admin/index/map-columns-form.php new file mode 100644 index 0000000..4859e10 --- /dev/null +++ b/views/admin/index/map-columns-form.php @@ -0,0 +1,31 @@ +
+columnNames; + $colExamples = $this->columnExamples; +?> + + + + + + + + + + + + + + + + + + form->getSubForm("row$i"); ?> + + + +
47) { echo '…';} ?>
+
+ form->submit; ?> +
+
diff --git a/views/admin/index/map-columns.php b/views/admin/index/map-columns.php new file mode 100644 index 0000000..b503a38 --- /dev/null +++ b/views/admin/index/map-columns.php @@ -0,0 +1,19 @@ + __('CSV Import'))); +?> + +
+

+ + form; ?> +
+ + diff --git a/views/admin/javascripts/csv-import.js b/views/admin/javascripts/csv-import.js new file mode 100644 index 0000000..7965af1 --- /dev/null +++ b/views/admin/javascripts/csv-import.js @@ -0,0 +1,51 @@ +if (!Omeka) { + var Omeka = {}; +} + +Omeka.CsvImport = {}; + +(function ($) { + /** + * Allow multiple mappings for each field, and add buttons to allow a mapping + * to be removed. + */ + Omeka.CsvImport.enableElementMapping = function () { + $('form#csvimport .map-element').change(function () { + var select = $(this); + var addButton = select.siblings('span.add-element'); + if (!addButton.length) { + var addButton = $(''); + addButton.click(function() { + var copy = select.clone(true); + select.after(copy); + $(this).remove(); + }); + select.after(addButton); + }; + }); + }; + + /** + * Add a confirm step before undoing an import. + */ + Omeka.CsvImport.confirm = function () { + $('.csv-undo-import').click(function () { + return confirm("Undoing an import will delete all of its imported items. Are you sure you want to undo this import?"); + }); + }; + + /** + * Disable most options if Import from Csv Report is checked + */ + Omeka.CsvImport.updateImportOptions = function () { + // we need to test whether the checkbox is checked + // because fields will all be displayed if the form fails validation + var fields = $('div.field').has('#automap_columns_names_to_elements, #item_type_id, #collection_id, #items_are_public, #items_are_featured, #column_delimiter, #element_delimiter, #tag_delimiter, #file_delimiter'); + if ($('#omeka_csv_export').is(':checked')) { + fields.slideUp(); + } else { + fields.slideDown(); + } + }; + +})(jQuery);