Index: trunk/grails-app/conf/BuildConfig.groovy
===================================================================
--- trunk/grails-app/conf/BuildConfig.groovy	(revision 635)
+++ trunk/grails-app/conf/BuildConfig.groovy	(revision 636)
@@ -85,4 +85,7 @@
         runtime ('org.apache.lucene:lucene-spellchecker:2.4.1')
 
+        runtime ('org.apache.ant:ant:1.7.1')
+        runtime ('org.apache.ant:ant-launcher:1.7.1')
+
     }
 
Index: trunk/grails-app/i18n/messages.properties
===================================================================
--- trunk/grails-app/i18n/messages.properties	(revision 635)
+++ trunk/grails-app/i18n/messages.properties	(revision 636)
@@ -15,4 +15,7 @@
 inventoryItemPictures.import.failure.no.directory=Import directory on server not found.
 inventoryItemPictures.import.failure.to.unzip=Failed to unzip supplied file.
+inventoryItemPictures.import=Picture Import
+inventoryItemPictures.import.help=A zip file of pictures (max 100MB) or singles pictures may be imported. Pictures \
+    must have the same file names as existing inventory items.
 
 inventoryItemPurchase.import.success=Inventory item purchases imported.
Index: trunk/grails-app/services/InventoryItemService.groovy
===================================================================
--- trunk/grails-app/services/InventoryItemService.groovy	(revision 635)
+++ trunk/grails-app/services/InventoryItemService.groovy	(revision 636)
@@ -1,3 +1,4 @@
 import org.codehaus.groovy.grails.commons.ConfigurationHolder
+import org.apache.commons.lang.WordUtils
 
 /**
@@ -7,4 +8,9 @@
 
     boolean transactional = false
+
+    def createDataService
+
+    def sessionFactory
+    def propertyInstanceMap = org.codehaus.groovy.grails.plugins.DomainClassGrailsPlugin.PROPERTY_INSTANCE_MAP
 
     /**
@@ -349,7 +355,7 @@
 
     /**
-    * Import inventory pictures from an uploaded zip file.
+    * Import inventory pictures from an uploaded zip file or picture.
     * @param request The http request to run getFile against.
-    * Get file should return a zip format file containing the inventory item pictures.
+    * Get file should return a zip format file containing the inventory item pictures or a picture file.
     */
     def importInventoryItemPictures(request) {
@@ -358,5 +364,5 @@
             def kByteMultiplier = 1000
             def mByteMultiplier = 1000 * kByteMultiplier
-            def fileMaxSize = 500 * mByteMultiplier
+            def fileMaxSize = 100 * mByteMultiplier
 
             def fail = { Map m ->
@@ -367,5 +373,5 @@
             // Get file from request.
             def multiPartFile = request.getFile('file')
-            def zipFileName = multiPartFile.originalFilename
+            def uploadedFileName = multiPartFile.originalFilename
 
             if(!multiPartFile || multiPartFile.isEmpty())
@@ -375,7 +381,6 @@
                 return fail(code: "default.file.over.max.size", args: [fileMaxSize/mByteMultiplier, "MB"])
 
-            // Check create import dir.
+            // Check and create import dir.
             def dir = new File(ConfigurationHolder.config.globalDirs.tempInventoryItemPicturesDirectory)
-            def imageFiles = []
 
             if(!dir.exists())
@@ -386,32 +391,39 @@
             }
 
-            // Write zip file to disk.
-            def zipOutputFile = new File(dir.absolutePath + File.separator + zipFileName)
-            multiPartFile.transferTo(zipOutputFile)
-
-            // Use ant to unzip.
-            def ant = new AntBuilder()
-            try {
-                ant.unzip(  src: zipOutputFile.absolutePath,
-                                    dest: dir.absolutePath,
-                                    overwrite:"true" )
-            }
-            catch(e) {
-                log.error e
-                return fail(code:'inventoryItemPictures.import.failure.to.unzip')
-            }
-
-            // Recurse through dir building list of imageFiles.
-            def imageFilePattern = ~/[^\s].+(\.(?i)(jpg|png|gif|bmp))$/
-
-            dir.eachFileMatch(imageFilePattern) {
-                imageFiles << it
+            // Write file to disk.
+            def diskFile = new File(dir.absolutePath + File.separator + uploadedFileName)
+            multiPartFile.transferTo(diskFile)
+
+            // File patterns
+            def zipFilePattern = ~/[^\s].*(\.(?i)(zip))$/
+            def pictureFilePattern = ~/[^\s].*(\.(?i)(jpg|png|gif|bmp))$/
+
+            // If file claims to be a zip file then try using ant to unzip.
+            if(diskFile.name.matches(zipFilePattern)) {
+                def ant = new AntBuilder()
+                try {
+                    ant.unzip(  src: diskFile.absolutePath,
+                                        dest: dir.absolutePath,
+                                        overwrite:"true" )
+                }
+                catch(e) {
+                    log.error e
+                    return fail(code:'inventoryItemPictures.import.failure.to.unzip')
+                }
+            }
+
+            // Recurse through dir building list of pictureFiles.
+            def pictureFiles = []
+            dir.eachFileMatch(pictureFilePattern) {
+                pictureFiles << it
             }
 
             dir.eachDirRecurse { subDir ->
-                subDir.eachFileMatch(imageFilePattern) {
-                    imageFiles << it
-                }
-            }
+                subDir.eachFileMatch(pictureFilePattern) {
+                    pictureFiles << it
+                }
+            }
+
+            pictureFiles.sort { p1, p2 -> p1.name.compareToIgnoreCase(p2.name) }
 
             // Find inventoryItems by name of picture and call savePicture.
@@ -419,7 +431,18 @@
             def itemName
             def savePictureResult
-
-            for(imageFile in imageFiles) {
-                itemName = imageFile.name[0..-5]
+            def pictureCount = 0
+            def picturesSavedCount = 0
+
+            // Turn off index mirroring.
+            createDataService.stopSearchableIndex()
+
+            for(pictureFile in pictureFiles) {
+                pictureCount++
+
+                if(pictureCount % 10 == 0) {
+                    cleanUpGorm()
+                }
+
+                itemName = WordUtils.capitalize(pictureFile.name[0..-5])
                 inventoryItemInstance = InventoryItem.findByName(itemName)
                 if(!inventoryItemInstance) {
@@ -431,9 +454,25 @@
                     continue
                 }
-                savePictureResult = savePicture(inventoryItemInstance, imageFile)
+                savePictureResult = savePicture(inventoryItemInstance, pictureFile)
                 if(savePictureResult.error)
                     log.error savePictureResult.error
+                else {
+                    picturesSavedCount++
+                    log.info 'InventoryItem picture saved: ' + itemName
+                }
+            }
+
+            // Start mirroring again and rebuild index.
+            createDataService.startSearchableIndex()
+
+            log.info 'InventoryItem pictures saved: ' + picturesSavedCount
+            log.info 'InventoryItem pictures total: ' + pictureCount
+
+            // Cleanup.
+            dir.eachFile() {
+                if(it.isDirectory())
+                    it.deleteDir()
                 else
-                    log.info 'InventoryItem picture saved: ' + itemName
+                    it.delete()
             }
 
@@ -443,3 +482,17 @@
     } // importInventoryItemPictures
 
+    /**
+    * This cleans up the hibernate session and a grails map.
+    * For more info see: http://naleid.com/blog/2009/10/01/batch-import-performance-with-grails-and-mysql/
+    * The hibernate session flush is normal for hibernate.
+    * The map is apparently used by grails for domain object validation errors.
+    * A starting point for clean up is every 100 objects.
+    */
+    def cleanUpGorm() {
+        def session = sessionFactory.currentSession
+        session.flush()
+        session.clear()
+        propertyInstanceMap.get().clear()
+    }
+
 } // end class
Index: trunk/grails-app/views/inventoryItemDetailed/importInventoryItemPictures.gsp
===================================================================
--- trunk/grails-app/views/inventoryItemDetailed/importInventoryItemPictures.gsp	(revision 635)
+++ trunk/grails-app/views/inventoryItemDetailed/importInventoryItemPictures.gsp	(revision 636)
@@ -18,8 +18,9 @@
                             <tr class="prop">
                                 <td valign="top" class="name">
-                                    <label for="file">Directory:</label>
+                                    <label for="file">File:</label>
                                 </td>
                                 <td valign="top" class="value">
                                     <input type="file" id="file" name="file" size="40"/>
+                                    <g:helpBalloon code="inventoryItemPictures.import" />
                                 </td>
                             </tr>
